vercel/next.js · #99736
[ai-upgrade] discover upgrade models and reasoning efforts from agent CLIs
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)+ }+ )+})