vercel/next.js · #99736

[ai-upgrade] discover upgrade models and reasoning efforts from agent CLIs

kassens · merged Oct 6, 20263 files · 1045 + / 333 −
packages/next/src/lib/upgrade/harness.ts182 + / 220 −
@@ -10,70 +10,9 @@ import * as Log from '../../build/output/log' import { getAgentName } from '../../telemetry/agent-name' import type { AgentUpgradeHandoffMethod } from '../../telemetry/events/agent-upgrade' import { bold, cyan, dim } from '../picocolors'+import { getHarnessModels, type UpgradeModel } from './model-discovery' import { runChildProcess } from './run-child-process' -const CODEX_EFFORTS = [-  'low',-  'medium',-  'high',-  'xhigh',-  'max',-  'ultra',-] as const-const CLAUDE_EFFORTS = ['low', 'medium', 'high', 'max'] as const--const UPGRADE_MODELS = {-  codex: [-    { id: 'gpt-5.6-terra', label: 'GPT-5.6-Terra', efforts: CODEX_EFFORTS },-    { id: 'gpt-5.6-sol', label: 'GPT-5.6-Sol', efforts: CODEX_EFFORTS },-    { id: 'gpt-6-astra', label: 'GPT-6-Astra', efforts: CODEX_EFFORTS },-  ],-  claude: [-    {-      id: 'claude-sonnet-5[1m]',-      label: 'Claude Sonnet 5 (1M)',-      efforts: CLAUDE_EFFORTS,-    },-    { id: 'opus', label: 'Claude Opus (latest)', efforts: CLAUDE_EFFORTS },-    { id: 'fable', label: 'Claude Fable (latest)', efforts: CLAUDE_EFFORTS },-  ],-} as const--function getCodexModels(path: string) {-  const result = spawn.sync(path, ['debug', 'models', '--bundled'], {-    encoding: 'utf8',-    timeout: 5000,-    maxBuffer: 20 * 1024 * 1024,-  })-  if (result.status !== 0) {-    Log.warn(-      'Could not read the Codex model catalog; using the CLI defaults.',-      result.error ?? (result.stderr?.trim() || `exit status ${result.status}`)-    )-    return []-  }--  try {-    const catalog = JSON.parse(result.stdout) as {-      models?: Array<{ slug: string }>-    }-    if (!Array.isArray(catalog.models)) {-      Log.warn(-        'Codex model catalog has an unexpected format; using the CLI defaults.'-      )-      return []-    }-    const available = new Set(catalog.models.map(({ slug }) => slug))-    return UPGRADE_MODELS.codex.filter(({ id }) => available.has(id))-  } catch (error) {-    Log.warn(-      'Could not parse the Codex model catalog; using the CLI defaults.',-      error-    )-    return []-  }-}- const CODEX_APPROVAL_ARGS = [   '--sandbox',   'workspace-write',@@ -82,7 +21,7 @@ const CODEX_APPROVAL_ARGS = [ ] as const  type UpgradeHarness = {-  name: keyof typeof UPGRADE_MODELS+  name: 'codex' | 'claude'   path: string } @@ -386,177 +325,200 @@ export async function handoffUpgrade(     return 'handed_off'   } -  let stage: 'harness' | 'model' | 'effort' | 'permission' | 'worktree' =-    'harness'-  let harness: UpgradeHarness | undefined-  let model: { id: string; efforts: readonly string[] } | undefined-  let effort: string | undefined-  let autoPermissionArgs: string[] | null = null-  let approvalPermissionArgs: string[] = []-  let permissionArgs: string[] = []-  let useAuto = true-  let useWorktree = true-  let codexModels: ReturnType<typeof getCodexModels> | undefined-  let codexAutoReview: boolean | undefined-  let claudePermissionSupport:-    | ReturnType<typeof getClaudePermissionSupport>-    | undefined--  while (true) {-    if (stage === 'harness') {-      const choice = await chooseHarness(installed, harness?.name)-      if (choice === 'copy') {-        const method = copyUpgradePrompt(resolvePrompt(prompt, null), false)-        onHandoff?.(method, null)-        return 'handed_off'-      }-      if (!choice) {-        break-      }-      harness = choice-      stage = 'model'-    } else if (stage === 'model') {-      const models =-        harness!.name === 'codex'-          ? (codexModels ??= getCodexModels(harness!.path))-          : UPGRADE_MODELS.claude-      if (models.length === 0 && harness!.name === 'codex') {-        Log.warn('Could not verify Codex models; using the CLI defaults.')-        model = undefined-        effort = 'default'-        stage = 'effort'-        continue-      }-      if (models.length === 0) {-        Log.error(-          'No supported models were found for the selected coding agent.'-        )-        process.exitCode = 1-        return 'failed'-      }-      const previousModelId = model?.id-      const modelId = await chooseOption(-        `Which ${getHarnessDisplayName(harness!.name)} model should run the upgrade?`,-        Object.fromEntries(models.map(({ id, label }) => [id, label])),-        Math.max(-          0,-          models.findIndex(({ id }) => id === previousModelId)-        )-      )-      if (modelId === null) {-        break-      }-      if (modelId === undefined) {-        stage = 'harness'-        continue-      }-      model = models.find(({ id }) => id === modelId)-      if (!model) {-        throw new Error(`Unknown upgrade model: ${modelId}`)-      }-      stage = 'effort'-    } else if (stage === 'effort') {-      if (model) {-        const efforts = ['default', ...model.efforts]-        const previousEffortIndex = efforts.indexOf(effort ?? 'default')-        const selectedEffort = await chooseOption(-          'Which reasoning effort should the upgrade use?',+  const discoveryController = new AbortController()+  const modelCatalogs = new Map(+    installed.map(({ name, path }) => [+      name,+      getHarnessModels(name, path, directory, discoveryController.signal),+    ])+  )+  const stopDiscovery = async () => {+    discoveryController.abort()+    await Promise.all(modelCatalogs.values())+  }++  try {+    let stage: 'harness' | 'model' | 'effort' | 'permission' | 'worktree' =+      'harness'+    let harness: UpgradeHarness | undefined+    let model: UpgradeModel | undefined+    let effort: string | undefined+    let autoPermissionArgs: string[] | null = null+    let approvalPermissionArgs: string[] = []+    let permissionArgs: string[] = []+    let useAuto = true+    let useWorktree = true+    const previousModelStage = () =>+      model ? (model.efforts.length > 0 ? 'effort' : 'model') : 'harness'+    let codexAutoReview: boolean | undefined+    let claudePermissionSupport:+      | ReturnType<typeof getClaudePermissionSupport>+      | undefined++    while (true) {+      if (stage === 'harness') {+        const choice = await chooseHarness(installed, harness?.name)+        if (choice === 'copy') {+          const method = copyUpgradePrompt(resolvePrompt(prompt, null), false)+          onHandoff?.(method, null)+          return 'handed_off'+        }+        if (!choice) {+          break+        }+        harness = choice+        stage = 'model'+      } else if (stage === 'model') {+        const models = await modelCatalogs.get(harness!.name)!+        if (models === null) {+          break+        }+        if (models.length === 0) {+          model = undefined+          effort = 'default'+          stage = 'effort'+          continue+        }+        const selectedModelId = model?.id+        const previousModelId = models.some(({ id }) => id === selectedModelId)+          ? selectedModelId+          : undefined+        const modelId = await chooseOption(+          `Which ${getHarnessDisplayName(harness!.name)} model should run the upgrade?`,           Object.fromEntries(-            efforts.map((value) => [-              value,-              value === 'default' ? 'Model default' : value,+            models.map(({ id, label, description }) => [+              id,+              description ? `${label} — ${description}` : label,             ])           ),-          Math.max(0, previousEffortIndex)+          Math.max(+            0,+            models.findIndex(({ id, isDefault }) =>+              previousModelId ? id === previousModelId : isDefault+            )+          )         )-        if (selectedEffort === null) {+        if (modelId === null) {           break         }-        if (selectedEffort === undefined) {-          stage = 'model'+        if (modelId === undefined) {+          stage = 'harness'           continue         }-        if (!efforts.includes(selectedEffort)) {-          throw new Error(`Unknown upgrade effort: ${selectedEffort}`)+        model = models.find(({ id }) => id === modelId)+        if (!model) {+          throw new Error(`Unknown upgrade model: ${modelId}`)         }-        effort = selectedEffort-      }-      if (harness!.name === 'codex') {-        codexAutoReview ??= supportsCodexAutoReview(harness!.path)-        autoPermissionArgs = codexAutoReview ? ['--approve-for-me'] : null-        approvalPermissionArgs = [...CODEX_APPROVAL_ARGS]-      } else {-        const { auto, approvalMode } = (claudePermissionSupport ??=-          getClaudePermissionSupport(harness!.path))-        if (!approvalMode) {-          Log.error('Could not determine a supported Claude approval mode.')-          process.exitCode = 1-          return 'failed'+        if (!model.efforts.includes(effort ?? 'default')) {+          effort = 'default'         }-        autoPermissionArgs = auto ? ['--permission-mode', 'auto'] : null-        approvalPermissionArgs = ['--permission-mode', approvalMode]-      }-      if (autoPermissionArgs) {-        stage = 'permission'-      } else {-        Log.info(-          dim('Auto permission mode is unavailable; using approval requests.')+        stage = 'effort'+      } else if (stage === 'effort') {+        if (model && model.efforts.length > 0) {+          const efforts = ['default', ...model.efforts]+          const previousEffortIndex = efforts.indexOf(effort ?? 'default')+          const selectedEffort = await chooseOption(+            'Which reasoning effort should the upgrade use?',+            Object.fromEntries(+              efforts.map((value) => [+                value,+                value === 'default' ? 'Model default' : value,+              ])+            ),+            Math.max(0, previousEffortIndex)+          )+          if (selectedEffort === null) {+            break+          }+          if (selectedEffort === undefined) {+            stage = 'model'+            continue+          }+          if (!efforts.includes(selectedEffort)) {+            throw new Error(`Unknown upgrade effort: ${selectedEffort}`)+          }+          effort = selectedEffort+        }+        if (harness!.name === 'codex') {+          codexAutoReview ??= supportsCodexAutoReview(harness!.path)+          autoPermissionArgs = codexAutoReview ? ['--approve-for-me'] : null+          approvalPermissionArgs = [...CODEX_APPROVAL_ARGS]+        } else {+          const { auto, approvalMode } = (claudePermissionSupport ??=+            getClaudePermissionSupport(harness!.path))+          if (!approvalMode) {+            Log.error('Could not determine a supported Claude approval mode.')+            process.exitCode = 1+            return 'failed'+          }+          autoPermissionArgs = auto ? ['--permission-mode', 'auto'] : null+          approvalPermissionArgs = ['--permission-mode', approvalMode]+        }+        if (autoPermissionArgs) {+          stage = 'permission'+        } else {+          Log.info(+            dim('Auto permission mode is unavailable; using approval requests.')+          )+          permissionArgs = approvalPermissionArgs+          stage = 'worktree'+        }+      } else if (stage === 'permission') {+        const permissionChoice = await chooseOption(+          `Use Auto permission mode for ${getHarnessDisplayName(harness!.name)}?`,+          { yes: 'Yes', no: 'No, ask for approval' },+          useAuto ? 0 : 1         )-        permissionArgs = approvalPermissionArgs+        if (permissionChoice === null) {+          break+        }+        if (permissionChoice === undefined) {+          stage = previousModelStage()+          continue+        }+        useAuto = permissionChoice === 'yes'+        permissionArgs = useAuto ? autoPermissionArgs! : approvalPermissionArgs         stage = 'worktree'-      }-    } else if (stage === 'permission') {-      const permissionChoice = await chooseOption(-        `Use Auto permission mode for ${getHarnessDisplayName(harness!.name)}?`,-        { yes: 'Yes', no: 'No, ask for approval' },-        useAuto ? 0 : 1-      )-      if (permissionChoice === null) {-        break-      }-      if (permissionChoice === undefined) {-        stage = model ? 'effort' : 'harness'-        continue-      }-      useAuto = permissionChoice === 'yes'-      permissionArgs = useAuto ? autoPermissionArgs! : approvalPermissionArgs-      stage = 'worktree'-    } else {-      const choice = await chooseWorktree()-      if (choice === null) {-        break-      }-      if (choice === undefined) {-        stage = autoPermissionArgs ? 'permission' : model ? 'effort' : 'harness'-        continue-      }-      useWorktree = choice-      Log.bootstrap(-        `  Continuing with ${cyan(bold(getHarnessDisplayName(harness!.name)))}...\n`-      )-      // Capture the selected agent before the launch callback runs.-      const agentProduct = harness!.name--      try {-        process.exitCode = await launchHarness(-          harness!,-          resolvePrompt(prompt, useWorktree),-          directory,-          model?.id ?? null,-          effort!,-          permissionArgs,-          () => onHandoff?.('launched_agent', agentProduct)+      } else {+        const choice = await chooseWorktree()+        if (choice === null) {+          break+        }+        if (choice === undefined) {+          stage = autoPermissionArgs ? 'permission' : previousModelStage()+          continue+        }+        useWorktree = choice+        Log.bootstrap(+          `  Continuing with ${cyan(bold(getHarnessDisplayName(harness!.name)))}...\n`         )-      } catch {-        Log.error(`Could not start ${getHarnessDisplayName(harness!.name)}.`)-        process.exitCode = 1-        return 'failed'+        // Capture the selected agent before the launch callback runs.+        const agentProduct = harness!.name+        await stopDiscovery()++        try {+          process.exitCode = await launchHarness(+            harness!,+            resolvePrompt(prompt, useWorktree),+            directory,+            model?.id ?? null,+            effort!,+            permissionArgs,+            () => onHandoff?.('launched_agent', agentProduct)+          )+        } catch {+          Log.error(`Could not start ${getHarnessDisplayName(harness!.name)}.`)+          process.exitCode = 1+          return 'failed'+        }+        return 'handed_off'       }-      return 'handed_off'     }-  } -  Log.bootstrap(`  ${dim('Upgrade cancelled.')}\n`)-  process.exitCode = 1-  return 'cancelled'+    Log.bootstrap(`  ${dim('Upgrade cancelled.')}\n`)+    process.exitCode = 1+    return 'cancelled'+  } finally {+    await stopDiscovery()+  } }
packages/next/src/lib/upgrade/model-discovery.tsadded295 + / 0 −
@@ -0,0 +1,295 @@+import { randomUUID } from 'crypto'+import spawn from 'next/dist/compiled/cross-spawn'+import createDebug from 'next/dist/compiled/debug'++const debug = createDebug('next:upgrade')++export type UpgradeModel = {+  id: string+  label: string+  description: string+  efforts: string[]+  isDefault: boolean+}++function record(value: unknown): Record<string, unknown> {+  return value !== null && typeof value === 'object' && !Array.isArray(value)+    ? (value as Record<string, unknown>)+    : {}+}++function string(value: unknown): string {+  return typeof value === 'string' ? value : ''+}++function efforts(value: unknown): string[] {+  return Array.isArray(value)+    ? [+        ...new Set(+          value.map(string).filter((level) => level && level !== 'default')+        ),+      ]+    : []+}++function parseModels(+  value: unknown,+  parse: (model: Record<string, unknown>) => UpgradeModel | null+): UpgradeModel[] {+  if (!Array.isArray(value)) {+    throw new Error('unexpected model catalog format')+  }+  return value+    .map((entry) => parse(record(entry)))+    .filter((model) => model !== null)+}++type DiscoveryProtocol = {+  initialize(): void+  receive(message: Record<string, unknown>): void+}++export function getCodexModels(+  path: string,+  cwd: string,+  signal?: AbortSignal+) {+  return discoverModels(+    'codex',+    path,+    ['app-server'],+    cwd,+    signal,+    (send, complete) => {+      const initializeId = randomUUID()+      let listId: string | undefined+      const models: UpgradeModel[] = []+      const cursors = new Set<string>()+      const list = (cursor?: string) => {+        listId = randomUUID()+        send({+          id: listId,+          method: 'model/list',+          params: { includeHidden: false, ...(cursor ? { cursor } : {}) },+        })+      }+      return {+        initialize() {+          send({+            id: initializeId,+            method: 'initialize',+            params: { clientInfo: { name: 'next_upgrade', version: '1' } },+          })+        },+        receive(message) {+          if (+            message.id !== initializeId &&+            (listId === undefined || message.id !== listId)+          )+            return+          if (message.error) throw new Error('CLI returned a protocol error')+          if (message.id === initializeId) {+            if (!message.result)+              throw new Error('missing initialization result')+            send({ method: 'initialized' })+            list()+            return+          }+          const response = record(message.result)+          models.push(+            ...parseModels(response.data, (model) => {+              const id = string(model.model)+              if (!id || model.hidden === true) return null+              return {+                id,+                label: string(model.displayName) || id,+                description: string(model.description),+                efforts: efforts(+                  Array.isArray(model.supportedReasoningEfforts)+                    ? model.supportedReasoningEfforts.map(+                        (level) => record(level).reasoningEffort+                      )+                    : undefined+                ),+                isDefault: model.isDefault === true,+              }+            })+          )+          const cursor = response.nextCursor+          if (cursor !== null && cursor !== undefined) {+            if (typeof cursor !== 'string' || !cursor || cursors.has(cursor)) {+              throw new Error('invalid pagination cursor')+            }+            cursors.add(cursor)+            list(cursor)+            return+          }+          complete(models)+        },+      }+    }+  )+}++export function getClaudeModels(+  path: string,+  cwd: string,+  signal?: AbortSignal+) {+  return discoverModels(+    'claude',+    path,+    [+      '-p',+      '--input-format',+      'stream-json',+      '--output-format',+      'stream-json',+      '--verbose',+      '--no-session-persistence',+    ],+    cwd,+    signal,+    (send, complete) => {+      const initializeId = randomUUID()+      return {+        initialize() {+          send({+            type: 'control_request',+            request_id: initializeId,+            request: { subtype: 'initialize' },+          })+        },+        receive(message) {+          if (message.type !== 'control_response') return+          const response = record(message.response)+          if (response.request_id !== initializeId) return+          if (response.subtype !== 'success')+            throw new Error('CLI returned an initialization error')+          complete(+            parseModels(record(response.response).models, (model) => {+              const id = string(model.value)+              if (!id) return null+              return {+                id,+                label: string(model.displayName) || id,+                description: string(model.description),+                efforts:+                  model.supportsEffort === false+                    ? []+                    : efforts(model.supportedEffortLevels),+                isDefault: id === 'default',+              }+            })+          )+        },+      }+    }+  )+}++export function getHarnessModels(+  name: 'codex' | 'claude',+  path: string,+  cwd: string,+  signal?: AbortSignal+): Promise<UpgradeModel[] | null> {+  return name === 'codex'+    ? getCodexModels(path, cwd, signal)+    : getClaudeModels(path, cwd, signal)+}++// null means discovery was cancelled, rather than an unavailable catalog.+async function discoverModels(+  name: string,+  path: string,+  args: string[],+  cwd: string,+  signal: AbortSignal | undefined,+  createProtocol: (+    send: (message: unknown) => void,+    complete: (models: UpgradeModel[]) => void+  ) => DiscoveryProtocol+): Promise<UpgradeModel[] | null> {+  if (signal?.aborted) return null+  let child: ReturnType<typeof spawn>+  try {+    child = spawn(path, args, { cwd, stdio: 'pipe' })+  } catch {+    debug('%s model discovery failed: could not start CLI', name)+    return []+  }++  return new Promise((resolve) => {+    let buffer = ''+    let bytes = 0+    let stopping = false+    let result: UpgradeModel[] | null = []+    let killTimer: ReturnType<typeof setTimeout> | undefined++    const stop = (value: UpgradeModel[] | null, reason?: string) => {+      if (stopping) return+      stopping = true+      result = value+      clearTimeout(timeout)+      if (reason) debug('%s model discovery failed: %s', name, reason)+      child.stdin?.end()+      child.kill('SIGTERM')+      killTimer = setTimeout(() => child.kill('SIGKILL'), 250)+    }+    const timeout = setTimeout(() => stop([], 'timed out'), 5000)+    const interrupt = () => stop(null)+    process.on('SIGINT', interrupt)+    process.on('SIGTERM', interrupt)+    process.on('SIGHUP', interrupt)++    const send = (message: unknown) => {+      if (!stopping) child.stdin?.write(JSON.stringify(message) + '\n')+    }+    const protocol = createProtocol(send, (models) => {+      const unique = [+        ...new Map(models.map((model) => [model.id, model])).values(),+      ]+      stop(unique, unique.length ? undefined : 'no usable models')+    })+    signal?.addEventListener('abort', interrupt, { once: true })+    const countOutput = (chunk: Buffer | string) => {+      bytes += Buffer.byteLength(chunk)+      if (bytes > 20 * 1024 * 1024) stop([], 'output limit exceeded')+    }+    child.stdout?.setEncoding('utf8')+    child.stdout?.on('data', (chunk: string) => {+      countOutput(chunk)+      if (stopping) return+      buffer += chunk+      let newline: number+      while (!stopping && (newline = buffer.indexOf('\n')) !== -1) {+        const line = buffer.slice(0, newline).trim()+        buffer = buffer.slice(newline + 1)+        if (!line) continue+        try {+          protocol.receive(record(JSON.parse(line)))+        } catch {+          stop([], 'invalid discovery response')+        }+      }+    })+    // Drain stderr, but never log raw CLI output (which may contain credentials).+    child.stderr?.on('data', countOutput)+    child.stdin?.on('error', () =>+      stop([], 'could not write discovery request')+    )+    child.on('error', () => stop([], 'could not start CLI'))+    child.once('close', () => {+      clearTimeout(timeout)+      clearTimeout(killTimer)+      process.removeListener('SIGINT', interrupt)+      process.removeListener('SIGTERM', interrupt)+      process.removeListener('SIGHUP', interrupt)+      signal?.removeEventListener('abort', interrupt)+      if (!stopping) debug('%s model discovery failed: CLI exited early', name)+      resolve(result)+    })+    child.once('spawn', protocol.initialize)+  })+}
test/unit/agentic-upgrade-prompts.test.ts568 + / 113 −
@@ -15,6 +15,7 @@ import cliSelect from 'next/dist/compiled/cli-select' import { spawnNextUpgrade } from 'next/dist/cli/next-upgrade' import { findDir } from 'next/dist/lib/find-pages-dir' import { getProjectDir } from 'next/dist/lib/get-project-dir'+import { getHarnessModels } from 'next/dist/lib/upgrade/model-discovery' import { handoffUpgrade } from 'next/dist/lib/upgrade/harness' import { prepareUpgrade } from 'next/dist/lib/upgrade/prepare-upgrade' import loadConfig from 'next/dist/server/config'@@ -64,6 +65,9 @@ jest.mock('next/dist/lib/picocolors', () => ({   cyan: (text: string) => text,   dim: (text: string) => text, }))+jest.mock('next/dist/lib/upgrade/model-discovery', () => ({+  getHarnessModels: jest.fn(),+})) jest.mock('next/dist/lib/upgrade/prepare-upgrade', () => ({   prepareUpgrade: jest.fn(), }))@@ -169,24 +173,33 @@ describe('agentic upgrade prompts', () => {           flush: jest.fn().mockResolvedValue([]),         }) as never     )-    crossSpawn.sync.mockImplementation((_path, args) => {-      if (args[0] === 'debug') {-        return {-          status: 0,-          stdout: JSON.stringify({-            models: [-              { slug: 'gpt-5.6-terra' },-              { slug: 'gpt-5.6-sol' },-              { slug: 'gpt-6-astra' },-            ],-          }),-        }-      }-      return {-        status: 0,-        stdout:-          '  --approve-for-me  Route approval requests through automatic review\n  --permission-mode <mode>  Permission mode to use for the session\n                                        (choices: "acceptEdits", "auto", "manual")',-      }+    jest.mocked(getHarnessModels).mockImplementation(async (name) =>+      (name === 'codex'+        ? [+            ['gpt-5.6-terra', 'GPT-5.6-Terra'],+            ['gpt-5.6-sol', 'GPT-5.6-Sol'],+            ['gpt-6-astra', 'GPT-6-Astra'],+          ]+        : [+            ['claude-sonnet-5[1m]', 'Claude Sonnet 5 (1M)'],+            ['opus', 'Claude Opus (latest)'],+            ['fable', 'Claude Fable (latest)'],+          ]+      ).map(([id, label]) => ({+        id,+        label,+        description: '',+        efforts:+          name === 'codex'+            ? ['low', 'medium', 'high', 'xhigh', 'max', 'ultra']+            : ['low', 'medium', 'high', 'max'],+        isDefault: false,+      }))+    )+    crossSpawn.sync.mockReturnValue({+      status: 0,+      stdout:+        '  --approve-for-me  Route approval requests through automatic review\n  --permission-mode <mode>  Permission mode to use for the session\n                                        (choices: "acceptEdits", "auto", "manual")',     })     process.env.__NEXT_UPGRADE_USE_CURRENT_CLI = '1'     process.env.__NEXT_UPGRADE_EXPECTED_CLI_VERSION = cliVersion@@ -664,116 +677,250 @@ describe('agentic upgrade prompts', () => {     )   }) -  it('omits Astra when the selected Codex catalog does not contain it', async () => {+  it('uses discovered names, descriptions, defaults, and per-model efforts', async () => {     process.env.PATH = '/agents'     overrideTTY(process.stdin)     overrideTTY(process.stdout)     jest.mocked(getAgentName).mockResolvedValue(null)     jest.mocked(access).mockResolvedValue(undefined)     jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)-    crossSpawn.sync.mockImplementation((_path, args) => {-      if (args[0] === 'debug') {-        return {-          status: 0,-          stdout: JSON.stringify({-            models: [{ slug: 'gpt-5.6-terra' }, { slug: 'gpt-5.6-sol' }],-          }),-        }-      }-      return { status: 0, stdout: '  --approve-for-me' }-    })+    jest.mocked(getHarnessModels).mockResolvedValue([+      {+        id: 'future-model',+        label: 'Future',+        description: 'For upgrades',+        efforts: ['high'],+        isDefault: false,+      },+      {+        id: 'recommended-model',+        label: 'Recommended',+        description: '',+        efforts: ['low'],+        isDefault: true,+      },+    ])     jest       .mocked(cliSelect)-      .mockResolvedValueOnce({ id: 'codex' } as never)-      .mockResolvedValueOnce({ id: 'gpt-5.6-sol' } as never)-      .mockResolvedValueOnce({ id: 'default' } as never)-      .mockResolvedValueOnce({ id: 'yes' } as never)-      .mockResolvedValueOnce({ id: 'no' } as never)+      .mockResolvedValueOnce({ id: 'codex' })+      .mockResolvedValueOnce({ id: 'future-model' })+      .mockResolvedValueOnce({ id: 'high' })+      .mockResolvedValueOnce({ id: 'yes' })+      .mockResolvedValueOnce({ id: 'no' })     crossSpawn.mockImplementation(() => {       const child = new EventEmitter()       process.nextTick(() => child.emit('close', 0, null))       return child     })-     await handoffUpgrade('Upgrade prompt', '/workspace/app', null)--    expect(jest.mocked(cliSelect).mock.calls[1][0].values).toEqual({-      'gpt-5.6-terra': 'GPT-5.6-Terra',-      'gpt-5.6-sol': 'GPT-5.6-Sol',+    expect(jest.mocked(cliSelect).mock.calls[1][0]).toMatchObject({+      values: {+        'future-model': 'Future — For upgrades',+        'recommended-model': 'Recommended',+      },+      defaultValue: 1,     })-    expect(crossSpawn.sync).toHaveBeenCalledWith(+    expect(jest.mocked(cliSelect).mock.calls[2][0].values).toEqual({+      default: 'Model default',+      high: 'high',+    })+    expect(getHarnessModels).toHaveBeenCalledWith(+      'codex',       expectedHarnessPath('codex'),-      ['debug', 'models', '--bundled'],-      expect.objectContaining({ encoding: 'utf8' })+      '/workspace/app',+      expect.any(AbortSignal)     )   }) -  it.each([-    [-      'unavailable',-      { status: 1, stdout: '' },-      'Could not read the Codex model catalog; using the CLI defaults.',-      'exit status 1',-    ],-    [-      'invalid JSON',-      { status: 0, stdout: '{' },-      'Could not parse the Codex model catalog; using the CLI defaults.',-      expect.any(SyntaxError),-    ],-    [-      'unexpected format',-      { status: 0, stdout: '{}' },-      'Codex model catalog has an unexpected format; using the CLI defaults.',-      null,-    ],-    [-      'empty',-      { status: 0, stdout: JSON.stringify({ models: [] }) },-      'Could not verify Codex models; using the CLI defaults.',-      null,-    ],-  ])(-    'uses Codex defaults when the model catalog is %s',-    async (_, result, warning, cause) => {+  it.each(['codex', 'claude'])(+    'uses %s defaults without warnings when discovery is empty',+    async (agent) => {       process.env.PATH = '/agents'       overrideTTY(process.stdin)       overrideTTY(process.stdout)       jest.mocked(getAgentName).mockResolvedValue(null)       jest.mocked(access).mockResolvedValue(undefined)       jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)-      crossSpawn.sync.mockImplementation((_path, args) =>-        args[0] === 'debug'-          ? result-          : { status: 0, stdout: '  --approve-for-me' }-      )+      jest.mocked(getHarnessModels).mockResolvedValue([])       jest         .mocked(cliSelect)-        .mockResolvedValueOnce({ id: 'codex' } as never)-        .mockResolvedValueOnce({ id: 'yes' } as never)-        .mockResolvedValueOnce({ id: 'no' } as never)+        .mockResolvedValueOnce({ id: agent })+        .mockResolvedValueOnce({ id: 'yes' })+        .mockRejectedValueOnce(undefined)+        .mockRejectedValueOnce(undefined)+        .mockResolvedValueOnce({ id: agent })+        .mockResolvedValueOnce({ id: 'yes' })+        .mockResolvedValueOnce({ id: 'no' })       crossSpawn.mockImplementation(() => {         const child = new EventEmitter()         process.nextTick(() => child.emit('close', 0, null))         return child       })-       await handoffUpgrade('Upgrade prompt', '/workspace/app', null)--      expect(cliSelect).toHaveBeenCalledTimes(3)-      if (cause === null) {-        expect(Log.warn).toHaveBeenCalledWith(warning)-      } else {-        expect(Log.warn).toHaveBeenCalledWith(warning, cause)-      }+      expect(getHarnessModels).toHaveBeenCalledTimes(2)+      expect(Log.warn).not.toHaveBeenCalled()+      expect(Log.error).not.toHaveBeenCalled()       expect(crossSpawn).toHaveBeenCalledWith(-        expectedHarnessPath('codex'),-        ['--approve-for-me', 'Upgrade prompt'],+        expectedHarnessPath(agent),+        [+          ...(agent === 'codex'+            ? ['--approve-for-me']+            : ['--permission-mode', 'auto']),+          'Upgrade prompt',+        ],         { cwd: '/workspace/app', stdio: 'inherit' }       )     }   ) +  it('skips unavailable efforts and goes back directly to model selection', async () => {+    process.env.PATH = '/agents'+    overrideTTY(process.stdin)+    overrideTTY(process.stdout)+    jest.mocked(getAgentName).mockResolvedValue(null)+    jest.mocked(access).mockResolvedValue(undefined)+    jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)+    jest.mocked(getHarnessModels).mockResolvedValue([+      {+        id: 'default',+        label: 'Default',+        description: '',+        efforts: [],+        isDefault: true,+      },+    ])+    jest+      .mocked(cliSelect)+      .mockResolvedValueOnce({ id: 'claude' })+      .mockResolvedValueOnce({ id: 'default' })+      .mockRejectedValueOnce(undefined)+      .mockResolvedValueOnce({ id: 'default' })+      .mockResolvedValueOnce({ id: 'yes' })+      .mockResolvedValueOnce({ id: 'no' })+    crossSpawn.mockImplementation(() => {+      const child = new EventEmitter()+      process.nextTick(() => child.emit('close', 0, null))+      return child+    })+    await handoffUpgrade('Upgrade prompt', '/workspace/app', null)+    expect(jest.mocked(cliSelect).mock.calls[3][0].values).toEqual({+      default: 'Default',+    })+    expect(crossSpawn).toHaveBeenCalledWith(+      expectedHarnessPath('claude'),+      ['--model', 'default', '--permission-mode', 'auto', 'Upgrade prompt'],+      { cwd: '/workspace/app', stdio: 'inherit' }+    )+    expect(getHarnessModels).toHaveBeenCalledTimes(2)+  })++  it('prefetches both catalogs before agent selection and cancels unused discovery before launch', async () => {+    process.env.PATH = '/agents'+    overrideTTY(process.stdin)+    overrideTTY(process.stdout)+    jest.mocked(getAgentName).mockResolvedValue(null)+    jest.mocked(access).mockResolvedValue(undefined)+    jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)+    let resolveCodex!: (+      models: Awaited<ReturnType<typeof getHarnessModels>>+    ) => void+    const cancelled: string[] = []+    jest.mocked(getHarnessModels).mockImplementation(+      (name, _path, _cwd, signal) =>+        new Promise((resolve) => {+          if (name === 'codex') resolveCodex = resolve+          signal!.addEventListener(+            'abort',+            () => {+              cancelled.push(name)+              resolve(null)+            },+            { once: true }+          )+        })+    )+    jest+      .mocked(cliSelect)+      .mockImplementationOnce(async () => {+        expect(+          jest.mocked(getHarnessModels).mock.calls.map(([name]) => name)+        ).toEqual(['codex', 'claude'])+        resolveCodex([+          {+            id: 'fresh',+            label: 'Fresh',+            description: '',+            efforts: [],+            isDefault: true,+          },+        ])+        return { id: 'codex' }+      })+      .mockImplementationOnce(async () => {+        expect(cancelled).toEqual([])+        return { id: 'fresh' }+      })+      .mockResolvedValueOnce({ id: 'yes' })+      .mockResolvedValueOnce({ id: 'no' })+    crossSpawn.mockImplementation(() => {+      expect(cancelled).toContain('claude')+      const child = new EventEmitter()+      process.nextTick(() => child.emit('close', 0, null))+      return child+    })+    expect(await handoffUpgrade('Upgrade prompt', '/workspace/app', null)).toBe(+      'handed_off'+    )+  })++  it.each(['cancel', 'copy'])(+    'cleans up prefetched discovery when choosing %s',+    async (choice) => {+      process.env.PATH = '/agents'+      overrideTTY(process.stdin)+      overrideTTY(process.stdout)+      jest.mocked(getAgentName).mockResolvedValue(null)+      jest.mocked(access).mockResolvedValue(undefined)+      jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)+      const cancelled: string[] = []+      jest.mocked(getHarnessModels).mockImplementation(+        (name, _path, _cwd, signal) =>+          new Promise((resolve) => {+            signal!.addEventListener(+              'abort',+              () => {+                cancelled.push(name)+                resolve(null)+              },+              { once: true }+            )+          })+      )+      if (choice === 'copy')+        jest.mocked(cliSelect).mockResolvedValueOnce({ id: 'copy' })+      else jest.mocked(cliSelect).mockRejectedValueOnce(undefined)+      await handoffUpgrade('Upgrade prompt', '/workspace/app', null)+      expect(cancelled).toEqual(['codex', 'claude'])+      expect(crossSpawn).not.toHaveBeenCalled()+    }+  )++  it('cancels when model discovery is interrupted', async () => {+    process.env.PATH = '/agents'+    overrideTTY(process.stdin)+    overrideTTY(process.stdout)+    jest.mocked(getAgentName).mockResolvedValue(null)+    jest.mocked(access).mockResolvedValue(undefined)+    jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)+    jest.mocked(getHarnessModels).mockResolvedValue(null)+    jest.mocked(cliSelect).mockResolvedValueOnce({ id: 'codex' })+    expect(await handoffUpgrade('Upgrade prompt', '/workspace/app', null)).toBe(+      'cancelled'+    )+    expect(cliSelect).toHaveBeenCalledTimes(1)+    expect(crossSpawn).not.toHaveBeenCalled()+  })+   it('preserves unexpected agent probe errors as the cause', async () => {     process.env.PATH = '/agents'     overrideTTY(process.stdin)@@ -844,13 +991,7 @@ describe('agentic upgrade prompts', () => {     jest.mocked(getAgentName).mockResolvedValue(null)     jest.mocked(access).mockResolvedValue(undefined)     jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)-    crossSpawn.sync.mockImplementation((_path, args) => {-      if (args[0] === 'debug') {-        return {-          status: 0,-          stdout: JSON.stringify({ models: [{ slug: 'gpt-5.6-terra' }] }),-        }-      }+    crossSpawn.sync.mockImplementation(() => {       return { status: 0, stdout: '  --ask-for-approval <APPROVAL_POLICY>' }     })     jest@@ -996,9 +1137,7 @@ describe('agentic upgrade prompts', () => {      await handoffUpgrade('Upgrade prompt', '/workspace/app', null) -    expect(-      crossSpawn.sync.mock.calls.filter(([, args]) => args[0] === 'debug')-    ).toHaveLength(1)+    expect(jest.mocked(getHarnessModels).mock.calls).toHaveLength(2)     expect(       crossSpawn.sync.mock.calls.filter(([, args]) => args[0] === '--help')     ).toHaveLength(1)@@ -1119,13 +1258,7 @@ describe('agentic upgrade prompts', () => {     jest.mocked(getAgentName).mockResolvedValue(null)     jest.mocked(access).mockResolvedValue(undefined)     jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)-    crossSpawn.sync.mockImplementation((_path, args) => {-      if (args[0] === 'debug') {-        return {-          status: 0,-          stdout: JSON.stringify({ models: [{ slug: 'gpt-5.6-terra' }] }),-        }-      }+    crossSpawn.sync.mockImplementation(() => {       return { status: 0, stdout: '  --ask-for-approval <APPROVAL_POLICY>' }     })     jest@@ -1191,13 +1324,7 @@ describe('agentic upgrade prompts', () => {     jest.mocked(getAgentName).mockResolvedValue(null)     jest.mocked(access).mockResolvedValue(undefined)     jest.mocked(stat).mockResolvedValue({ isFile: () => true } as never)-    crossSpawn.sync.mockImplementation((_path, args) => {-      if (args[0] === 'debug') {-        return {-          status: 0,-          stdout: JSON.stringify({ models: [{ slug: 'gpt-5.6-terra' }] }),-        }-      }+    crossSpawn.sync.mockImplementation(() => {       return { status: 0, stdout: '  --approve-for-me' }     })     jest@@ -1764,3 +1891,331 @@ describe('agentic upgrade prompts', () => {     `)   }) })++describe('upgrade model discovery protocol', () => {+  const discover: typeof getHarnessModels = jest.requireActual(+    'next/dist/lib/upgrade/model-discovery'+  ).getHarnessModels++  function probe(+    onRequest: (message: any, respond: (value: unknown) => void) => void+  ) {+    const child = Object.assign(new EventEmitter(), {+      stdin: Object.assign(new EventEmitter(), {+        write: jest.fn((line: string) => {+          onRequest(JSON.parse(line), (value) => {+            const json = JSON.stringify(value) + '\n'+            // Exercise both split frames and multiple frames in one chunk.+            child.stdout.emit('data', json.slice(0, 5))+            child.stdout.emit('data', json.slice(5))+          })+        }),+        end: jest.fn(),+      }),+      stdout: Object.assign(new EventEmitter(), { setEncoding: jest.fn() }),+      stderr: new EventEmitter(),+      kill: jest.fn((_signal: string) => {+        process.nextTick(() => child.emit('close', null, 'SIGTERM'))+        return true+      }),+    })+    crossSpawn.mockImplementation(() => {+      process.nextTick(() => child.emit('spawn'))+      return child+    })+    return child+  }++  beforeEach(() => jest.resetAllMocks())+  afterEach(() => jest.useRealTimers())++  it('initializes Codex and follows pagination using opaque model IDs and advertised efforts', async () => {+    const requests: any[] = []+    const child = probe((message, respond) => {+      requests.push(message)+      if (message.method === 'initialize') {+        respond({ method: 'unrelated/notification', params: {} })+        respond({ id: message.id, result: {} })+      } else if (message.method === 'model/list') {+        respond({+          id: message.id,+          result: message.params.cursor+            ? {+                data: [+                  {+                    model: 'new-model',+                    displayName: 'New model',+                    description: 'Useful',+                    isDefault: true,+                    supportedReasoningEfforts: [+                      { reasoningEffort: 'future-effort' },+                    ],+                  },+                ],+                nextCursor: null,+              }+            : {+                data: [+                  { model: 'hidden', hidden: true },+                  {+                    model: 'first',+                    supportedReasoningEfforts: [{ reasoningEffort: 'low' }],+                  },+                ],+                nextCursor: 'page-2',+              },+        })+      }+    })+    expect(await discover('codex', '/agents/codex', '/project')).toEqual([+      {+        id: 'first',+        label: 'first',+        description: '',+        efforts: ['low'],+        isDefault: false,+      },+      {+        id: 'new-model',+        label: 'New model',+        description: 'Useful',+        efforts: ['future-effort'],+        isDefault: true,+      },+    ])+    expect(requests.map((request) => request.method)).toEqual([+      'initialize',+      'initialized',+      'model/list',+      'model/list',+    ])+    expect(requests[3].params).toEqual({+      includeHidden: false,+      cursor: 'page-2',+    })+    expect(crossSpawn).toHaveBeenCalledWith('/agents/codex', ['app-server'], {+      cwd: '/project',+      stdio: 'pipe',+    })+    expect(child.stdin.end).toHaveBeenCalled()+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+  })++  it('reads Claude aliases, descriptions, default choice, and effort capabilities', async () => {+    const child = probe((message, respond) => {+      expect(message.type).toBe('control_request')+      expect(message.request).toEqual({ subtype: 'initialize' })+      respond({ type: 'system', subtype: 'init' })+      respond({+        type: 'control_response',+        response: { request_id: 'unrelated', subtype: 'error' },+      })+      respond({+        type: 'control_response',+        response: {+          request_id: message.request_id,+          subtype: 'success',+          response: {+            models: [+              {+                value: 'default',+                resolvedModel: 'concrete-model',+                displayName: 'Recommended',+                description: 'Latest',+                supportedEffortLevels: ['low', 'high'],+              },+              {+                value: 'haiku',+                supportsEffort: false,+                supportedEffortLevels: ['high'],+              },+              { value: 'older' },+            ],+          },+        },+      })+    })+    expect(await discover('claude', '/agents/claude', '/project')).toEqual([+      {+        id: 'default',+        label: 'Recommended',+        description: 'Latest',+        efforts: ['low', 'high'],+        isDefault: true,+      },+      {+        id: 'haiku',+        label: 'haiku',+        description: '',+        efforts: [],+        isDefault: false,+      },+      {+        id: 'older',+        label: 'older',+        description: '',+        efforts: [],+        isDefault: false,+      },+    ])+    expect(crossSpawn).toHaveBeenCalledWith(+      '/agents/claude',+      [+        '-p',+        '--input-format',+        'stream-json',+        '--output-format',+        'stream-json',+        '--verbose',+        '--no-session-persistence',+      ],+      { cwd: '/project', stdio: 'pipe' }+    )+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+  })++  it.each(['codex', 'claude'] as const)(+    'returns no models for an empty %s catalog',+    async (name) => {+      probe((message, respond) => {+        if (message.method === 'initialize')+          respond({ id: message.id, result: {} })+        if (message.method === 'model/list')+          respond({ id: message.id, result: { data: [] } })+        if (message.type === 'control_request')+          respond({+            type: 'control_response',+            response: {+              request_id: message.request_id,+              subtype: 'success',+              response: { models: [] },+            },+          })+      })+      expect(await discover(name, '/agent', '/project')).toEqual([])+    }+  )++  it.each([+    'malformed JSON',+    'unexpected format',+    'protocol error',+    'repeated cursor',+  ])('handles %s and cleans up', async (failure) => {+    const child = probe((message, respond) => {+      if (message.method === 'initialize') {+        if (failure === 'malformed JSON') child.stdout.emit('data', '{\n')+        else respond({ id: message.id, result: {} })+      } else if (message.method === 'model/list') {+        respond(+          failure === 'protocol error'+            ? { id: message.id, error: { code: -1 } }+            : {+                id: message.id,+                result:+                  failure === 'unexpected format'+                    ? {}+                    : { data: [], nextCursor: 'same' },+              }+        )+      }+    })+    expect(await discover('codex', '/agent', '/project')).toEqual([])+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+  })++  it('handles a Claude initialization error', async () => {+    probe((message, respond) =>+      respond({+        type: 'control_response',+        response: {+          request_id: message.request_id,+          subtype: 'error',+          error: 'private diagnostic',+        },+      })+    )+    expect(await discover('claude', '/agent', '/project')).toEqual([])+  })++  it('handles process and stdin errors without warnings', async () => {+    const child = probe(() => child.stdin.emit('error', new Error('EPIPE')))+    expect(await discover('codex', '/agent', '/project')).toEqual([])+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+    child.kill.mockClear()+    const failed = probe(() => failed.emit('error', new Error('ENOENT')))+    expect(await discover('codex', '/agent', '/project')).toEqual([])+    expect(Log.warn).not.toHaveBeenCalled()+    expect(Log.error).not.toHaveBeenCalled()+  })++  it('handles a thrown spawn error and an early process exit', async () => {+    crossSpawn.mockImplementation(() => {+      throw new Error('spawn failed')+    })+    expect(await discover('codex', '/agent', '/project')).toEqual([])+    const child = probe(() => child.emit('close', 1, null))+    expect(await discover('codex', '/agent', '/project')).toEqual([])+  })++  it('bounds output from both stdout and stderr', async () => {+    const child = probe(() => {+      child.stdout.emit('data', ' '.repeat(10 * 1024 * 1024))+      child.stderr.emit('data', Buffer.alloc(11 * 1024 * 1024))+    })+    expect(await discover('codex', '/agent', '/project')).toEqual([])+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+  })++  it('bounds the whole probe and force-kills a process that ignores termination', async () => {+    jest.useFakeTimers({ doNotFake: ['nextTick'] })+    const child = probe(() => {})+    child.kill.mockImplementation((signal) => {+      if (signal === 'SIGKILL')+        process.nextTick(() => child.emit('close', null, signal))+      return true+    })+    const result = discover('codex', '/agent', '/project')+    await new Promise<void>((resolve) => process.nextTick(resolve))+    jest.advanceTimersByTime(5000)+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+    jest.advanceTimersByTime(250)+    expect(await result).toEqual([])+    expect(child.kill).toHaveBeenCalledWith('SIGKILL')+    expect(jest.getTimerCount()).toBe(0)+  })++  it('cancels a running probe through its abort signal', async () => {+    const controller = new AbortController()+    const child = probe(() => controller.abort())+    expect(+      await discover('claude', '/agent', '/project', controller.signal)+    ).toBeNull()+    expect(child.kill).toHaveBeenCalledWith('SIGTERM')+  })++  it('does not spawn a probe whose signal is already aborted', async () => {+    const controller = new AbortController()+    controller.abort()+    expect(+      await discover('codex', '/agent', '/project', controller.signal)+    ).toBeNull()+    expect(crossSpawn).not.toHaveBeenCalled()+  })++  it.each(['SIGINT', 'SIGTERM', 'SIGHUP'] as const)(+    'cancels and removes listeners on %s',+    async (signal) => {+      const initial = process.listenerCount(signal)+      // Jest's process.on is bound to the host process; its copied emitter+      // does not dispatch those listeners through process.emit.+      const child = probe(() => {+        const listener = process.listeners(signal).at(-1)!+        listener(signal)+      })+      expect(await discover('codex', '/agent', '/project')).toBeNull()+      expect(child.kill).toHaveBeenCalledWith('SIGTERM')+      expect(process.listenerCount(signal)).toBe(initial)+    }+  )+})