From 2fd1484cc02de502f0771204690e1c29a5e1b587 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 19 Jul 2026 17:21:38 +0800 Subject: [PATCH 1/7] fix(cli): make npm updates registry-consistent --- packages/cli/src/ui/utils/updateCheck.test.ts | 142 ++++++++++++++++++ packages/cli/src/ui/utils/updateCheck.ts | 103 +++++++++++-- .../cli/src/utils/handleAutoUpdate.test.ts | 25 +++ packages/cli/src/utils/handleAutoUpdate.ts | 18 ++- packages/cli/src/utils/installationInfo.ts | 10 ++ .../cli/src/utils/update-relaunch.test.ts | 8 +- packages/cli/src/utils/update-relaunch.ts | 6 +- 7 files changed, 293 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index a2b1d0d8589..6f13e8a0956 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -8,7 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'; import { checkForUpdates, checkForUpdatesDetailed, + fetchGlobalNpmUpdateInfo, FETCH_TIMEOUT_MS, + isGlobalNpmInstallation, + runGlobalNpm, UpdateCheckTimeoutError, } from './updateCheck.js'; @@ -203,6 +206,145 @@ describe('checkForUpdates', () => { }); }); + it('checks npm updates in the global npm context', async () => { + const run = vi.fn().mockResolvedValue({ + stdout: '"1.1.0"\n', + stderr: '', + }); + + await expect( + fetchGlobalNpmUpdateInfo( + '@qwen-code/qwen-code', + '1.0.0', + 'latest', + run as unknown as NonNullable< + Parameters[3] + >, + ), + ).resolves.toMatchObject({ current: '1.0.0', latest: '1.1.0' }); + const npmArgs = [ + 'view', + '@qwen-code/qwen-code', + 'dist-tags.latest', + '--json', + '--global', + ]; + expect(run).toHaveBeenCalledWith( + process.platform === 'win32' ? process.execPath : 'npm', + process.platform === 'win32' + ? [expect.stringMatching(/npm-cli\.js$/), ...npmArgs] + : npmArgs, + expect.objectContaining({ + timeout: FETCH_TIMEOUT_MS, + }), + ); + }); + + it('does not treat pnpm installs as global npm installs', async () => { + const run = vi.fn(); + + await expect( + isGlobalNpmInstallation( + '/home/user/.pnpm/@qwen-code+qwen-code/node_modules/@qwen-code/qwen-code/dist/index.js', + run as unknown as NonNullable< + Parameters[1] + >, + ), + ).resolves.toBe(false); + expect(run).not.toHaveBeenCalled(); + }); + + it('runs the Windows npm CLI through Node without a shell', async () => { + const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' }); + const resolveNpmCliPath = vi + .fn() + .mockReturnValue( + 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', + ); + + await runGlobalNpm( + ['view', '@qwen-code/qwen-code'], + run as unknown as NonNullable[1]>, + 'win32', + 'C:\\Program Files\\nodejs\\node.exe', + resolveNpmCliPath, + ); + + expect(run).toHaveBeenCalledWith( + 'C:\\Program Files\\nodejs\\node.exe', + [ + 'C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js', + 'view', + '@qwen-code/qwen-code', + ], + expect.not.objectContaining({ shell: true }), + ); + }); + + it('canonicalizes the global npm root before comparing paths', async () => { + const run = vi.fn().mockResolvedValue({ + stdout: '/linked/node_modules\n', + stderr: '', + }); + const canonicalize = vi.fn(async (candidate: string) => + candidate === '/linked/node_modules' + ? '/real/node_modules' + : '/real/node_modules/@qwen-code/qwen-code/cli.js', + ); + + await expect( + isGlobalNpmInstallation( + '/linked/node_modules/@qwen-code/qwen-code/cli.js', + run as unknown as NonNullable< + Parameters[1] + >, + canonicalize as unknown as NonNullable< + Parameters[2] + >, + ), + ).resolves.toBe(true); + }); + + it('does not treat a missing global npm root as a global install', async () => { + const run = vi.fn().mockResolvedValue({ + stdout: '/missing/node_modules\n', + stderr: '', + }); + const canonicalize = vi.fn(async (candidate: string) => { + if (candidate === '/missing/node_modules') { + throw Object.assign(new Error('not found'), { code: 'ENOENT' }); + } + return '/local/node_modules/@qwen-code/qwen-code/cli.js'; + }); + + await expect( + isGlobalNpmInstallation( + '/local/node_modules/@qwen-code/qwen-code/cli.js', + run as unknown as NonNullable< + Parameters[1] + >, + canonicalize as unknown as NonNullable< + Parameters[2] + >, + ), + ).resolves.toBe(false); + }); + + it('does not fall back when the global npm query fails', async () => { + const run = vi.fn().mockRejectedValue(new Error('npm view failed')); + + await expect( + fetchGlobalNpmUpdateInfo( + '@qwen-code/qwen-code', + '1.0.0', + 'latest', + run as unknown as NonNullable< + Parameters[3] + >, + ), + ).rejects.toThrow('npm view failed'); + }); + it('should pass a non-optional package version to update-notifier', async () => { getPackageJson.mockResolvedValue({ name: 'test-package', diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 3cea93cc731..58fd714beba 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -7,7 +7,12 @@ import type { UpdateInfo } from 'update-notifier'; import updateNotifier from 'update-notifier'; import semver from 'semver'; +import { execFile } from 'node:child_process'; +import { realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { promisify } from 'node:util'; import { getPackageJson } from '../../utils/package.js'; +import { getWindowsNpmCliPath } from '../../utils/installationInfo.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -59,6 +64,81 @@ async function fetchInfoWithTimeout( } } +const execFileAsync = promisify(execFile); + +export async function runGlobalNpm( + args: string[], + run: typeof execFileAsync = execFileAsync, + platform = process.platform, + nodePath = process.execPath, + resolveNpmCliPath = getWindowsNpmCliPath, +): Promise { + const isWindows = platform === 'win32'; + const command = isWindows ? nodePath : 'npm'; + const commandArgs = isWindows ? [resolveNpmCliPath(nodePath), ...args] : args; + const { stdout } = await run(command, commandArgs, { + encoding: 'utf8', + timeout: FETCH_TIMEOUT_MS, + }); + return String(stdout).trim(); +} + +function looksLikeNpmPackagePath(cliPath: string): boolean { + const normalized = cliPath.replace(/\\/g, '/'); + return ( + normalized.includes('/node_modules/@qwen-code/qwen-code/') && + !normalized.includes('/.pnpm/') + ); +} + +export async function isGlobalNpmInstallation( + cliPath = process.argv[1], + run: typeof execFileAsync = execFileAsync, + canonicalize: typeof realpath = realpath, +): Promise { + if (!cliPath || !looksLikeNpmPackagePath(cliPath)) return false; + const [resolvedCliPath, unresolvedGlobalRoot] = await Promise.all([ + canonicalize(cliPath), + runGlobalNpm(['root', '--global'], run), + ]); + let globalRoot: string; + try { + globalRoot = await canonicalize(unresolvedGlobalRoot); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + const relative = path.relative(globalRoot, resolvedCliPath); + return ( + relative !== '' && + relative !== '..' && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +export async function fetchGlobalNpmUpdateInfo( + packageName: string, + currentVersion: string, + distTag: 'latest' | 'nightly', + run: typeof execFileAsync = execFileAsync, +): Promise { + const output = await runGlobalNpm( + ['view', packageName, `dist-tags.${distTag}`, '--json', '--global'], + run, + ); + const latest: unknown = JSON.parse(output); + if (typeof latest !== 'string') { + throw new Error(`Invalid npm ${distTag} version response`); + } + return { + latest, + current: currentVersion, + type: 'latest', + name: packageName, + }; +} + export interface UpdateObject { message: string; update: UpdateInfo; @@ -106,18 +186,23 @@ export async function checkForUpdatesDetailed(): Promise { } const { name, version } = packageJson; + const isGlobalNpm = await isGlobalNpmInstallation(); currentVersion = version; const isNightly = version.includes('nightly'); const createNotifier = (distTag: 'latest' | 'nightly') => - updateNotifier({ - pkg: { - name, - version, - }, - updateCheckInterval: 0, - shouldNotifyInNpmScript: true, - distTag, - }); + isGlobalNpm + ? { + fetchInfo: () => fetchGlobalNpmUpdateInfo(name, version, distTag), + } + : updateNotifier({ + pkg: { + name, + version, + }, + updateCheckInterval: 0, + shouldNotifyInNpmScript: true, + distTag, + }); if (isNightly) { const [nightlyUpdateInfo, latestUpdateInfo] = await Promise.all([ diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 9fb09a7501d..3aa7e6ccea2 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -14,6 +14,7 @@ import EventEmitter from 'node:events'; import { handleAutoUpdate, setUpdateHandler } from './handleAutoUpdate.js'; import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; +import os from 'node:os'; vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); @@ -242,6 +243,30 @@ describe('handleAutoUpdate', () => { ); }); + it('runs npm through the active Node.js runtime on Windows', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'npm install -g @qwen-code/qwen-code@latest', + isGlobal: true, + packageManager: PackageManager.NPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + + expect(mockSpawn).toHaveBeenCalledWith( + process.execPath, + [ + expect.stringMatching(/npm-cli\.js$/), + 'install', + '-g', + '@qwen-code/qwen-code@2.0.0', + ], + { + stdio: ['pipe', 'ignore', 'pipe'], + }, + ); + }); + it('should emit "update-success" when the update process succeeds', async () => { await new Promise((resolve) => { mockGetInstallationInfo.mockReturnValue({ diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index afa777697b3..53cc8a117c9 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -7,7 +7,9 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import { + getWindowsNpmCliPath, getInstallationInfo, + PackageManager, resolveUpdateCommand, } from './installationInfo.js'; import { updateEventEmitter } from './updateEventEmitter.js'; @@ -94,9 +96,19 @@ export function handleAutoUpdate( info.update.latest, ); const isWindows = os.platform() === 'win32'; - const shell = isWindows ? 'cmd.exe' : 'bash'; - const shellArgs = isWindows ? ['/c', updateCommand] : ['-c', updateCommand]; - const updateProcess = spawnFn(shell, shellArgs, { + const command = + isWindows && installationInfo.packageManager === PackageManager.NPM + ? process.execPath + : isWindows + ? 'cmd.exe' + : 'bash'; + const commandArgs = + isWindows && installationInfo.packageManager === PackageManager.NPM + ? [getWindowsNpmCliPath(), ...updateCommand.split(' ').slice(1)] + : isWindows + ? ['/c', updateCommand] + : ['-c', updateCommand]; + const updateProcess = spawnFn(command, commandArgs, { stdio: ['pipe', 'ignore', 'pipe'], }); let errorOutput = ''; diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index c32398ba1f4..5f6b38e9fd8 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -22,6 +22,16 @@ export enum PackageManager { UNKNOWN = 'unknown', } +export function getWindowsNpmCliPath(nodePath = process.execPath): string { + return path.win32.join( + path.win32.dirname(nodePath), + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ); +} + const debugLogger = createDebugLogger('INSTALLATION_INFO'); const STANDALONE_UNIX_INSTALLER = 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen-standalone.sh'; diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index a6a57ebacda..d4ee11f6ac9 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -61,24 +61,24 @@ describe('updateBeforeRelaunch', () => { ); }); - it('reports update failure and returns so the old version can relaunch', async () => { + it('reports update failure and relaunches the old version', async () => { const updateProcess = new EventEmitter(); handleAutoUpdate.mockReturnValue(updateProcess); const update = updateBeforeRelaunch(settings, '/repo'); await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); updateProcess.emit('close', 1); - await expect(update).resolves.toBe(false); + await expect(update).resolves.toBe(true); expect(writeStderrLine).toHaveBeenCalledWith( 'Automatic update failed. Please try updating manually.', ); }); - it('does not relaunch when the update check fails', async () => { + it('relaunches the old version when the update check fails', async () => { checkForUpdatesDetailed.mockResolvedValue({ status: 'error' }); - await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(false); + await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(true); expect(writeStderrLine).toHaveBeenCalledWith( 'Failed to check for updates. Please check your network or registry configuration.', ); diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index 794cae84875..f8a9430878d 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -56,7 +56,7 @@ export async function updateBeforeRelaunch( installationInfo.updateMessage ?? t('Manual update required. Please reinstall Qwen Code.'), ); - return false; + return true; } const updateProcess = handleAutoUpdate( result.info, @@ -76,12 +76,12 @@ export async function updateBeforeRelaunch( : UPDATE_FAILED_MESSAGE, ), ); - return success; + return true; } else if (result.status === 'error') { writeStderrLine(t(UPDATE_CHECK_FAILED_MESSAGE)); } } catch { writeStderrLine(translate(UPDATE_FAILED_MESSAGE)); } - return false; + return true; } From 75decb5d8d58cb868f3f17fbc33dce2f719db4f6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 19 Jul 2026 18:08:57 +0800 Subject: [PATCH 2/7] fix(cli): harden npm updates and exit handling --- packages/cli/src/gemini.test.tsx | 8 +++- packages/cli/src/gemini.tsx | 3 +- packages/cli/src/ui/utils/updateCheck.test.ts | 42 ++++++++++++++++-- packages/cli/src/ui/utils/updateCheck.ts | 28 +++++++----- .../cli/src/utils/handleAutoUpdate.test.ts | 12 ++--- packages/cli/src/utils/handleAutoUpdate.ts | 14 +++--- packages/cli/src/utils/installationInfo.ts | 22 ++++++---- packages/cli/src/utils/relaunch.test.ts | 4 +- packages/cli/src/utils/relaunch.ts | 6 +-- .../cli/src/utils/update-relaunch.test.ts | 44 ++++++++++++------- packages/cli/src/utils/update-relaunch.ts | 7 +-- 11 files changed, 128 insertions(+), 62 deletions(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 596e434795e..9aad75b4f52 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -967,9 +967,13 @@ describe('gemini.tsx main function', () => { const { relaunchOnExitCode } = await import('./utils/relaunch.js'); const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!; - await expect(options?.onUpdateRelaunch?.()).resolves.toBe(44); + await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44); - expect(mockUpdateBeforeRelaunch).toHaveBeenCalledTimes(1); + expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith( + expect.anything(), + expect.any(String), + true, + ); }); it('passes host update capability into a container sandbox', async () => { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index dd5e9d2a9b3..08634692799 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -400,7 +400,7 @@ export async function main() { ? getNodeMemoryArgs(isDebugMode) : []; const updateProjectRoot = process.cwd(); - const onUpdateRelaunch = async () => { + const onUpdateRelaunch = async (relaunchOnFailure: boolean) => { await initializeI18n( resolveLanguageSetting(settings.merged.general?.language as string), ); @@ -410,6 +410,7 @@ export async function main() { const shouldRelaunch = await updateBeforeRelaunch( settings, updateProjectRoot, + relaunchOnFailure, ); return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0; }; diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index 6f13e8a0956..bc255e75f48 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -230,16 +230,41 @@ describe('checkForUpdates', () => { '--global', ]; expect(run).toHaveBeenCalledWith( - process.platform === 'win32' ? process.execPath : 'npm', - process.platform === 'win32' - ? [expect.stringMatching(/npm-cli\.js$/), ...npmArgs] - : npmArgs, + process.execPath, + [expect.stringMatching(/npm-cli\.js$/), ...npmArgs], expect.objectContaining({ timeout: FETCH_TIMEOUT_MS, }), ); }); + it('selects the global npm registry for global npm installs', async () => { + getPackageJson.mockResolvedValue({ + name: '@qwen-code/qwen-code', + version: '1.0.0', + }); + const detectGlobalNpm = vi.fn().mockResolvedValue(true); + const fetchGlobalNpm = vi.fn().mockResolvedValue({ + current: '1.0.0', + latest: '1.1.0', + }); + + await expect( + checkForUpdatesDetailed(detectGlobalNpm, fetchGlobalNpm), + ).resolves.toMatchObject({ + status: 'update', + info: { update: { current: '1.0.0', latest: '1.1.0' } }, + }); + + expect(detectGlobalNpm).toHaveBeenCalledOnce(); + expect(fetchGlobalNpm).toHaveBeenCalledWith( + '@qwen-code/qwen-code', + '1.0.0', + 'latest', + ); + expect(updateNotifier).not.toHaveBeenCalled(); + }); + it('does not treat pnpm installs as global npm installs', async () => { const run = vi.fn(); @@ -279,6 +304,10 @@ describe('checkForUpdates', () => { ], expect.not.objectContaining({ shell: true }), ); + expect(resolveNpmCliPath).toHaveBeenCalledWith( + 'C:\\Program Files\\nodejs\\node.exe', + 'win32', + ); }); it('canonicalizes the global npm root before comparing paths', async () => { @@ -303,6 +332,11 @@ describe('checkForUpdates', () => { >, ), ).resolves.toBe(true); + expect(run).toHaveBeenCalledWith( + process.execPath, + [expect.stringMatching(/npm-cli\.js$/), 'root', '--global'], + expect.objectContaining({ timeout: FETCH_TIMEOUT_MS }), + ); }); it('does not treat a missing global npm root as a global install', async () => { diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 58fd714beba..7aae97d5fac 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -12,7 +12,7 @@ import { realpath } from 'node:fs/promises'; import path from 'node:path'; import { promisify } from 'node:util'; import { getPackageJson } from '../../utils/package.js'; -import { getWindowsNpmCliPath } from '../../utils/installationInfo.js'; +import { getNpmCliPath } from '../../utils/installationInfo.js'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -71,15 +71,16 @@ export async function runGlobalNpm( run: typeof execFileAsync = execFileAsync, platform = process.platform, nodePath = process.execPath, - resolveNpmCliPath = getWindowsNpmCliPath, + resolveNpmCliPath = getNpmCliPath, ): Promise { - const isWindows = platform === 'win32'; - const command = isWindows ? nodePath : 'npm'; - const commandArgs = isWindows ? [resolveNpmCliPath(nodePath), ...args] : args; - const { stdout } = await run(command, commandArgs, { - encoding: 'utf8', - timeout: FETCH_TIMEOUT_MS, - }); + const { stdout } = await run( + nodePath, + [resolveNpmCliPath(nodePath, platform), ...args], + { + encoding: 'utf8', + timeout: FETCH_TIMEOUT_MS, + }, + ); return String(stdout).trim(); } @@ -173,7 +174,10 @@ function getBestAvailableUpdate( return semver.gt(stableVer, nightlyVer) ? stable : nightly; } -export async function checkForUpdatesDetailed(): Promise { +export async function checkForUpdatesDetailed( + detectGlobalNpm = isGlobalNpmInstallation, + fetchGlobalNpm = fetchGlobalNpmUpdateInfo, +): Promise { let currentVersion: string | undefined; try { // Skip update check when running from source (development mode) @@ -186,13 +190,13 @@ export async function checkForUpdatesDetailed(): Promise { } const { name, version } = packageJson; - const isGlobalNpm = await isGlobalNpmInstallation(); + const isGlobalNpm = await detectGlobalNpm(); currentVersion = version; const isNightly = version.includes('nightly'); const createNotifier = (distTag: 'latest' | 'nightly') => isGlobalNpm ? { - fetchInfo: () => fetchGlobalNpmUpdateInfo(name, version, distTag), + fetchInfo: () => fetchGlobalNpm(name, version, distTag), } : updateNotifier({ pkg: { diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 3aa7e6ccea2..94a2184bea7 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -232,11 +232,13 @@ describe('handleAutoUpdate', () => { handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); expect(mockSpawn).toHaveBeenCalledWith( - expect.stringMatching(/^(bash|cmd\.exe)$/), - expect.arrayContaining([ - expect.stringMatching(/^(-c|\/c)$/), - 'npm i -g @qwen-code/qwen-code@nightly', - ]), + process.execPath, + [ + expect.stringMatching(/npm-cli\.js$/), + 'i', + '-g', + '@qwen-code/qwen-code@nightly', + ], { stdio: ['pipe', 'ignore', 'pipe'], }, diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 53cc8a117c9..c302b48acc9 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -7,7 +7,7 @@ import type { UpdateObject } from '../ui/utils/updateCheck.js'; import type { LoadedSettings } from '../config/settings.js'; import { - getWindowsNpmCliPath, + getNpmCliPath, getInstallationInfo, PackageManager, resolveUpdateCommand, @@ -95,16 +95,20 @@ export function handleAutoUpdate( installationInfo.updateCommand, info.update.latest, ); - const isWindows = os.platform() === 'win32'; + const platform = os.platform(); + const isWindows = platform === 'win32'; const command = - isWindows && installationInfo.packageManager === PackageManager.NPM + installationInfo.packageManager === PackageManager.NPM ? process.execPath : isWindows ? 'cmd.exe' : 'bash'; const commandArgs = - isWindows && installationInfo.packageManager === PackageManager.NPM - ? [getWindowsNpmCliPath(), ...updateCommand.split(' ').slice(1)] + installationInfo.packageManager === PackageManager.NPM + ? [ + getNpmCliPath(process.execPath, platform), + ...updateCommand.split(' ').slice(1), + ] : isWindows ? ['/c', updateCommand] : ['-c', updateCommand]; diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 5f6b38e9fd8..6578dae0e3f 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -22,14 +22,20 @@ export enum PackageManager { UNKNOWN = 'unknown', } -export function getWindowsNpmCliPath(nodePath = process.execPath): string { - return path.win32.join( - path.win32.dirname(nodePath), - 'node_modules', - 'npm', - 'bin', - 'npm-cli.js', - ); +export function getNpmCliPath( + nodePath = process.execPath, + platform = process.platform, +): string { + if (platform === 'win32') { + return path.win32.join( + path.win32.dirname(nodePath), + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ); + } + return fs.realpathSync(path.join(path.dirname(nodePath), 'npm')); } const debugLogger = createDebugLogger('INSTALLATION_INFO'); diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 8c91609523a..bfeda18c7b8 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -93,7 +93,7 @@ describe('relaunchOnExitCode', () => { relaunchOnExitCode(runner, { onUpdateRelaunch }), ).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).toHaveBeenCalledTimes(1); + expect(onUpdateRelaunch).toHaveBeenCalledWith(true); expect(runner).toHaveBeenCalledTimes(1); expect(processExitSpy).toHaveBeenCalledWith(0); }); @@ -354,7 +354,7 @@ describe('relaunchAppInChildProcess', () => { mockChild.emit('close', 0); await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).toHaveBeenCalledTimes(1); + expect(onUpdateRelaunch).toHaveBeenCalledWith(false); expect(processExitSpy).toHaveBeenCalledWith(44); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index 9407ac8a8cf..e4572d1360a 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -14,7 +14,7 @@ import { writeStderrLine } from './stdioHelpers.js'; interface RelaunchOptions { afterSpawn?: () => void; - onUpdateRelaunch?: () => Promise | number; + onUpdateRelaunch?: (relaunchOnFailure: boolean) => Promise | number; } export async function relaunchOnExitCode( @@ -26,7 +26,7 @@ export async function relaunchOnExitCode( const exitCode = await runner(); if (exitCode === UPDATE_RELAUNCH_EXIT_CODE && options?.onUpdateRelaunch) { - const updatedExitCode = await options.onUpdateRelaunch(); + const updatedExitCode = await options.onUpdateRelaunch(true); process.exit(updatedExitCode); } @@ -114,7 +114,7 @@ export async function relaunchAppInChildProcess( options?.onUpdateRelaunch ) { updateOnExitRequested = false; - void Promise.resolve(options.onUpdateRelaunch()).then( + void Promise.resolve(options.onUpdateRelaunch(false)).then( (updatedExitCode) => resolve(updatedExitCode), reject, ); diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index d4ee11f6ac9..557bdb9823c 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -46,7 +46,7 @@ describe('updateBeforeRelaunch', () => { const updateProcess = new EventEmitter(); handleAutoUpdate.mockReturnValue(updateProcess); - const update = updateBeforeRelaunch(settings, '/repo'); + const update = updateBeforeRelaunch(settings, '/repo', false); await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); expect(writeStderrLine).toHaveBeenCalledWith('Update available'); expect(writeStderrLine).not.toHaveBeenCalledWith( @@ -61,24 +61,32 @@ describe('updateBeforeRelaunch', () => { ); }); - it('reports update failure and relaunches the old version', async () => { - const updateProcess = new EventEmitter(); - handleAutoUpdate.mockReturnValue(updateProcess); - - const update = updateBeforeRelaunch(settings, '/repo'); - await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); - updateProcess.emit('close', 1); - await expect(update).resolves.toBe(true); - - expect(writeStderrLine).toHaveBeenCalledWith( - 'Automatic update failed. Please try updating manually.', - ); - }); + it.each([ + ['explicit update', true, true], + ['background update-on-exit', false, false], + ] as const)( + 'reports %s failure and returns %s', + async (_source, relaunchOnFailure, expected) => { + const updateProcess = new EventEmitter(); + handleAutoUpdate.mockReturnValue(updateProcess); + + const update = updateBeforeRelaunch(settings, '/repo', relaunchOnFailure); + await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); + updateProcess.emit('close', 1); + await expect(update).resolves.toBe(expected); + + expect(writeStderrLine).toHaveBeenCalledWith( + 'Automatic update failed. Please try updating manually.', + ); + }, + ); it('relaunches the old version when the update check fails', async () => { checkForUpdatesDetailed.mockResolvedValue({ status: 'error' }); - await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(true); + await expect(updateBeforeRelaunch(settings, '/repo', true)).resolves.toBe( + true, + ); expect(writeStderrLine).toHaveBeenCalledWith( 'Failed to check for updates. Please check your network or registry configuration.', ); @@ -96,7 +104,7 @@ describe('updateBeforeRelaunch', () => { }), ); - const update = updateBeforeRelaunch(settings, '/repo'); + const update = updateBeforeRelaunch(settings, '/repo', false); await vi.waitFor(() => expect(performStandaloneUpdate).toHaveBeenCalledWith('/qwen', '2.0.0'), ); @@ -113,7 +121,9 @@ describe('updateBeforeRelaunch', () => { }); performStandaloneUpdate.mockResolvedValue('deferred'); - await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(false); + await expect(updateBeforeRelaunch(settings, '/repo', false)).resolves.toBe( + false, + ); expect(writeStderrLine).toHaveBeenCalledWith( 'Update downloaded. It will be applied after you exit this session.', ); diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index f8a9430878d..d618888b8dd 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -15,6 +15,7 @@ const UPDATE_FAILED_MESSAGE = export async function updateBeforeRelaunch( settings: LoadedSettings, projectRoot: string, + relaunchOnFailure: boolean, ): Promise { let translate = (message: string) => message; try { @@ -56,7 +57,7 @@ export async function updateBeforeRelaunch( installationInfo.updateMessage ?? t('Manual update required. Please reinstall Qwen Code.'), ); - return true; + return relaunchOnFailure; } const updateProcess = handleAutoUpdate( result.info, @@ -76,12 +77,12 @@ export async function updateBeforeRelaunch( : UPDATE_FAILED_MESSAGE, ), ); - return true; + return success || relaunchOnFailure; } else if (result.status === 'error') { writeStderrLine(t(UPDATE_CHECK_FAILED_MESSAGE)); } } catch { writeStderrLine(translate(UPDATE_FAILED_MESSAGE)); } - return true; + return relaunchOnFailure; } From 5c5b529a769b87d675c1449af3ba4ff5b5fbf4df Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 19 Jul 2026 19:15:35 +0800 Subject: [PATCH 3/7] fix(cli): restart safely for automatic updates --- docs/design/immediate-auto-update-relaunch.md | 36 +++++ packages/cli/src/cli.test.ts | 132 ++++++++++++++- packages/cli/src/gemini.test.tsx | 76 ++++++++- packages/cli/src/gemini.tsx | 24 ++- packages/cli/src/i18n/locales/ca.js | 4 - packages/cli/src/i18n/locales/de.js | 4 - packages/cli/src/i18n/locales/en.js | 4 - packages/cli/src/i18n/locales/fr.js | 4 - packages/cli/src/i18n/locales/ja.js | 4 - packages/cli/src/i18n/locales/pt.js | 4 - packages/cli/src/i18n/locales/ru.js | 4 - packages/cli/src/i18n/locales/zh-TW.js | 4 - packages/cli/src/i18n/locales/zh.js | 4 - .../cli/src/startup/startup-prefetch.test.ts | 71 +++++--- packages/cli/src/startup/startup-prefetch.ts | 25 +-- packages/cli/src/ui/AppContainer.tsx | 73 ++++++--- .../src/ui/commands/update-command.test.ts | 73 ++++++++- .../cli/src/ui/commands/update-command.ts | 92 +++++++---- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 9 ++ .../cli/src/ui/hooks/useMessageQueue.test.ts | 3 + packages/cli/src/ui/hooks/useMessageQueue.ts | 3 + .../cli/src/utils/handleAutoUpdate.test.ts | 152 ++++++++++++++++++ packages/cli/src/utils/handleAutoUpdate.ts | 46 ++++++ packages/cli/src/utils/processUtils.test.ts | 94 +++++++++-- packages/cli/src/utils/processUtils.ts | 50 +++++- packages/cli/src/utils/relaunch.test.ts | 33 +--- packages/cli/src/utils/relaunch.ts | 35 +--- packages/cli/src/utils/sandbox.test.ts | 9 ++ packages/cli/src/utils/sandbox.ts | 15 +- .../cli/src/utils/update-relaunch.test.ts | 44 ++--- packages/cli/src/utils/update-relaunch.ts | 7 +- scripts/cli-entry.js | 77 ++++++++- 33 files changed, 970 insertions(+), 249 deletions(-) create mode 100644 docs/design/immediate-auto-update-relaunch.md diff --git a/docs/design/immediate-auto-update-relaunch.md b/docs/design/immediate-auto-update-relaunch.md new file mode 100644 index 00000000000..3addfd859a1 --- /dev/null +++ b/docs/design/immediate-auto-update-relaunch.md @@ -0,0 +1,36 @@ +# Immediate Automatic Update Relaunch + +## Problem + +Updating a global installation in place while the interactive CLI is running +removes content-hashed chunks that the old process may still import. Deferring +the install until the user exits avoids that corruption, but delays every +automatic update and reopens a session the user intentionally closed. + +## Design + +Reuse the existing update relaunch handoff: + +1. The post-render update check discovers an installable update. +2. The UI exits with the update relaunch code after normal cleanup. If a model + or tool turn, prompt or automatic-notification queue, slash command, or + unsent draft is active, it waits for a safe idle boundary. +3. The supervisor rechecks and installs the update after the child is gone. +4. The stable launcher starts the updated CLI with the original options and + resumes the exact durable session without replaying the initial prompt. For + a session with no recorded messages, the handoff independently records + whether the initial prompt was already consumed: an unconsumed prompt still + runs, while an executed slash or shell command is not replayed. + +The production wrapper supplies an explicit capability marker only after it +resolves a stable launcher, plus a private handoff file for the session ID. +Container and macOS sandboxes receive the same handoff. If no stable launcher +is available, or a conversation cannot be resumed because recording is +disabled or failed, keep the current manual guidance. Custom or manually +managed sandbox hosts remain manual. Windows standalone installs download in +the background but apply after exit because the running executable is locked. + +## Non-goals + +This does not hot-swap modules in a running process or introduce versioned +installation directories. diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index d1b6dac2ff3..8ae4e3b2ef7 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -394,11 +394,11 @@ describe('bootstrap import boundaries', () => { ); writeFileSync( path.join(oldDir, 'cli.js'), - `import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`, + `import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ sessionId: '123e4567-e89b-12d3-a456-426614174000' }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`, ); writeFileSync( path.join(newDir, 'cli.js'), - "process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n", + "process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n", ); writeFileSync( binPath, @@ -420,8 +420,14 @@ describe('bootstrap import boundaries', () => { }); expect(JSON.parse(output)).toEqual({ - args: ['--prompt', 'a&b'], + args: [ + '--prompt', + 'a&b', + '--resume', + '123e4567-e89b-12d3-a456-426614174000', + ], skip: 'true', + skipPrompt: 'true', hasLauncherPid: true, }); } finally { @@ -430,6 +436,126 @@ describe('bootstrap import boundaries', () => { } }); + it('relaunches a fresh session without forcing resume or skipping its prompt', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-fresh-update-')); + const oldDir = path.join(tempDir, 'old'); + const newDir = path.join(tempDir, 'new'); + const binPath = path.join(tempDir, 'qwen'); + try { + mkdirSync(oldDir); + mkdirSync(newDir); + copyFileSync( + '../../scripts/cli-entry.js', + path.join(oldDir, 'entry.mjs'), + ); + copyFileSync( + '../../scripts/cli-entry.js', + path.join(newDir, 'entry.mjs'), + ); + writeFileSync( + path.join(oldDir, 'cli.js'), + `import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`, + ); + writeFileSync( + path.join(newDir, 'cli.js'), + 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n', + ); + writeFileSync( + binPath, + `#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`, + ); + chmodSync(binPath, 0o755); + + const output = execFileSync(binPath, ['--prompt-interactive', 'hello'], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + }, + }); + + expect(JSON.parse(output)).toEqual({ + args: ['--prompt-interactive', 'hello'], + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not replay a consumed initial command in a fresh session', () => { + const tempDir = mkdtempSync( + path.join(tmpdir(), 'qwen-cli-command-update-'), + ); + const oldDir = path.join(tempDir, 'old'); + const newDir = path.join(tempDir, 'new'); + const binPath = path.join(tempDir, 'qwen'); + try { + mkdirSync(oldDir); + mkdirSync(newDir); + copyFileSync( + '../../scripts/cli-entry.js', + path.join(oldDir, 'entry.mjs'), + ); + copyFileSync( + '../../scripts/cli-entry.js', + path.join(newDir, 'entry.mjs'), + ); + writeFileSync( + path.join(oldDir, 'cli.js'), + `import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ skipInitialPrompt: true }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`, + ); + writeFileSync( + path.join(newDir, 'cli.js'), + 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n', + ); + writeFileSync( + binPath, + `#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`, + ); + chmodSync(binPath, 0o755); + + const output = execFileSync( + binPath, + ['--prompt-interactive', '/update'], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + }, + }, + ); + + expect(JSON.parse(output)).toEqual({ + args: ['--prompt-interactive', '/update'], + skipPrompt: 'true', + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('does not advertise update relaunch without a stable launcher', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-no-launcher-')); + const entryPath = path.join(tempDir, 'entry.mjs'); + try { + copyFileSync('../../scripts/cli-entry.js', entryPath); + writeFileSync( + path.join(tempDir, 'cli.js'), + 'process.stdout.write(JSON.stringify({ supported: process.env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED, statePath: process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH }));\n', + ); + + const output = execFileSync(process.execPath, [entryPath], { + encoding: 'utf8', + env: { ...process.env, PATH: '' }, + }); + + expect(JSON.parse(output)).toEqual({}); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('does not pass the standalone launcher hint to child processes', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-launcher-env-')); const entryPath = path.join(tempDir, 'entry.mjs'); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9aad75b4f52..08c5b6129c8 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -14,6 +14,7 @@ import { type MockInstance, } from 'vitest'; import { readFileSync } from 'node:fs'; +import os from 'node:os'; import { createNonInteractivePromptId, main, @@ -885,9 +886,22 @@ describe('gemini.tsx main function', () => { argv: string[], sessionId = '123e4567-e89b-12d3-a456-426614174000', command: 'docker' | 'podman' | 'sandbox-exec' = 'sandbox-exec', + hasUpdateSupervisor = true, ): Promise => { const originalArgv = process.argv; + const originalUpdateSupported = + process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; + const originalUpdateStatePath = + process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; process.argv = argv; + if (hasUpdateSupervisor) { + process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'] = 'true'; + process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'] = + '/tmp/qwen-update-state.json'; + } else { + delete process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; + delete process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; + } const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { @@ -939,6 +953,18 @@ describe('gemini.tsx main function', () => { } } finally { process.argv = originalArgv; + if (originalUpdateSupported === undefined) { + delete process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; + } else { + process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'] = + originalUpdateSupported; + } + if (originalUpdateStatePath === undefined) { + delete process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; + } else { + process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'] = + originalUpdateStatePath; + } processExitSpy.mockRestore(); } @@ -967,12 +993,11 @@ describe('gemini.tsx main function', () => { const { relaunchOnExitCode } = await import('./utils/relaunch.js'); const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!; - await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44); + await expect(options?.onUpdateRelaunch?.()).resolves.toBe(44); expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith( expect.anything(), expect.any(String), - true, ); }); @@ -1000,6 +1025,53 @@ describe('gemini.tsx main function', () => { } }); + it('does not advertise host update relaunch without a stable launcher', async () => { + const originalCapability = process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; + + try { + await runSandboxRelaunch( + ['node', 'script.js', '--debug', '-p', 'hello'], + '', + 'docker', + false, + ); + + expect(process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']).toBe('false'); + } finally { + if (originalCapability === undefined) { + delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; + } else { + process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = originalCapability; + } + } + }); + + it('keeps Windows standalone host updates deferred', async () => { + const originalCapability = process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; + const platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); + mockGetInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: 'C:\\qwen-code', + }); + + try { + await runSandboxRelaunch( + ['node', 'script.js', '--debug', '-p', 'hello'], + '', + 'docker', + ); + + expect(process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']).toBe('false'); + } finally { + platformSpy.mockRestore(); + if (originalCapability === undefined) { + delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; + } else { + process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = originalCapability; + } + } + }); + it('does not pass an empty session ID into the sandbox child process', async () => { const sandboxArgs = await runSandboxRelaunch( ['node', 'script.js', '--debug', '-p', 'hello'], diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08634692799..dc70bfdf695 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -86,7 +86,9 @@ import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, + SKIP_INITIAL_PROMPT_ENV_VAR, UPDATE_COMPLETE_EXIT_CODE, + canRelaunchForUpdate, } from './utils/processUtils.js'; import { getInstallationInfo } from './utils/installationInfo.js'; @@ -295,6 +297,17 @@ export async function main() { markAcpStartup('argsParseStart'); let argv = await parseArguments(); + if (process.env[SKIP_INITIAL_PROMPT_ENV_VAR] === 'true') { + if (process.env['QWEN_CODE_NO_RELAUNCH'] || process.env['SANDBOX']) { + delete process.env[SKIP_INITIAL_PROMPT_ENV_VAR]; + } + argv = { + ...argv, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + }; + } markAcpStartup('argsParseEnd'); profileCheckpoint('after_parse_arguments'); @@ -400,7 +413,7 @@ export async function main() { ? getNodeMemoryArgs(isDebugMode) : []; const updateProjectRoot = process.cwd(); - const onUpdateRelaunch = async (relaunchOnFailure: boolean) => { + const onUpdateRelaunch = async () => { await initializeI18n( resolveLanguageSetting(settings.merged.general?.language as string), ); @@ -410,7 +423,6 @@ export async function main() { const shouldRelaunch = await updateBeforeRelaunch( settings, updateProjectRoot, - relaunchOnFailure, ); return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0; }; @@ -431,9 +443,11 @@ export async function main() { const hostInstallationInfo = getInstallationInfo(updateProjectRoot, true); process.env[HOST_UPDATE_RELAUNCH_ENV_VAR] = String( Boolean( - hostInstallationInfo.updateCommand || - (hostInstallationInfo.isStandalone && - hostInstallationInfo.standaloneDir), + canRelaunchForUpdate() && + (hostInstallationInfo.updateCommand || + (hostInstallationInfo.isStandalone && + hostInstallationInfo.standaloneDir && + os.platform() !== 'win32')), ), ); } diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index db8aa330801..c0e00fe233f 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2145,10 +2145,6 @@ export default { 'Aquesta sessió utilitza la imatge de sandbox personalitzada {{image}}. Actualitzeu la imatge i reinicieu Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': "Actualitzeu Qwen Code a l'amfitrió i reinicieu l'entorn aïllat.", - 'The update will be installed after you exit this session.': - "L'actualització s'instal·larà després de sortir d'aquesta sessió.", - 'Run /update to install the update on the host.': - "Executeu /update per instal·lar l'actualització a l'amfitrió.", 'Run /update to install the update.': "Executeu /update per instal·lar l'actualització.", diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d9c91153ed4..c14dcb813f1 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2189,10 +2189,6 @@ export default { 'Diese Sitzung verwendet das benutzerdefinierte Sandbox-Image {{image}}. Aktualisieren Sie das Image und starten Sie Qwen Code neu.', 'Update Qwen Code on the host, then restart the sandbox.': 'Aktualisieren Sie Qwen Code auf dem Host und starten Sie anschließend die Sandbox neu.', - 'The update will be installed after you exit this session.': - 'Das Update wird nach dem Beenden dieser Sitzung installiert.', - 'Run /update to install the update on the host.': - 'Führen Sie /update aus, um das Update auf dem Host zu installieren.', 'Run /update to install the update.': 'Führen Sie /update aus, um das Update zu installieren.', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 78fb3242f95..4a64faca237 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2646,10 +2646,6 @@ export default { 'This session uses the custom sandbox image {{image}}. Update that image and restart Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Update Qwen Code on the host, then restart the sandbox.', - 'The update will be installed after you exit this session.': - 'The update will be installed after you exit this session.', - 'Run /update to install the update on the host.': - 'Run /update to install the update on the host.', 'Run /update to install the update.': 'Run /update to install the update.', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 0a3d33039e1..f0ee0031523 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2192,10 +2192,6 @@ export default { 'Cette session utilise l’image de bac à sable personnalisée {{image}}. Mettez à jour l’image et redémarrez Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Mettez à jour Qwen Code sur l’hôte, puis redémarrez le bac à sable.', - 'The update will be installed after you exit this session.': - 'La mise à jour sera installée après la fermeture de cette session.', - 'Run /update to install the update on the host.': - 'Exécutez /update pour installer la mise à jour sur l’hôte.', 'Run /update to install the update.': 'Exécutez /update pour installer la mise à jour.', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 34741bb2f5e..a93c7497f82 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1956,10 +1956,6 @@ export default { 'このセッションではカスタムサンドボックスイメージ {{image}} を使用しています。イメージを更新して Qwen Code を再起動してください。', 'Update Qwen Code on the host, then restart the sandbox.': 'ホスト上の Qwen Code を更新してから、サンドボックスを再起動してください。', - 'The update will be installed after you exit this session.': - 'このセッションを終了すると、更新が自動的にインストールされます。', - 'Run /update to install the update on the host.': - '/update を実行してホストに更新をインストールしてください。', 'Run /update to install the update.': '/update を実行して更新をインストールしてください。', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 54866ad2099..ced981da418 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2175,10 +2175,6 @@ export default { 'Esta sessão usa a imagem de sandbox personalizada {{image}}. Atualize a imagem e reinicie o Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Atualize o Qwen Code no host e reinicie o sandbox.', - 'The update will be installed after you exit this session.': - 'A atualização será instalada após você sair desta sessão.', - 'Run /update to install the update on the host.': - 'Execute /update para instalar a atualização no host.', 'Run /update to install the update.': 'Execute /update para instalar a atualização.', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 6747f4e87e5..ce11470229e 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2163,10 +2163,6 @@ export default { 'В этом сеансе используется пользовательский образ песочницы {{image}}. Обновите образ и перезапустите Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Обновите Qwen Code на хосте, затем перезапустите песочницу.', - 'The update will be installed after you exit this session.': - 'Обновление будет установлено после выхода из этого сеанса.', - 'Run /update to install the update on the host.': - 'Запустите /update, чтобы установить обновление на хосте.', 'Run /update to install the update.': 'Запустите /update, чтобы установить обновление.', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 294b51bbfc2..cd3eb112d3f 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2237,10 +2237,6 @@ export default { '此工作階段使用自訂沙箱映像 {{image}}。請更新該映像並重新啟動 Qwen Code。', 'Update Qwen Code on the host, then restart the sandbox.': '請在主機上更新 Qwen Code,然後重新啟動沙箱。', - 'The update will be installed after you exit this session.': - '結束目前工作階段後將自動安裝更新。', - 'Run /update to install the update on the host.': - '執行 /update 在主機上安裝更新。', 'Run /update to install the update.': '執行 /update 安裝更新。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 歷史記錄缺口:此處之前的會話記錄已遺失(儲存中斷),且無法找回。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 113c84e44ff..da8be676669 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2439,10 +2439,6 @@ export default { '此会话使用自定义沙箱镜像 {{image}}。请更新该镜像并重启 Qwen Code。', 'Update Qwen Code on the host, then restart the sandbox.': '请在宿主机上更新 Qwen Code,然后重启沙箱。', - 'The update will be installed after you exit this session.': - '退出当前会话后将自动安装更新。', - 'Run /update to install the update on the host.': - '运行 /update 在宿主机上安装更新。', 'Run /update to install the update.': '运行 /update 安装更新。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 历史记录缺口:此处之前的会话记录已丢失(存储中断),且无法找回。', diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 5a278ff9581..fd64761b8cd 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -23,7 +23,7 @@ const mockPreconnectApi = vi.hoisted(() => vi.fn()); const mockRecordStartupEvent = vi.hoisted(() => vi.fn()); const mockCheckForUpdatesDetailed = vi.hoisted(() => vi.fn()); const mockHandleAutoUpdate = vi.hoisted(() => vi.fn()); -const mockRequestUpdateOnExit = vi.hoisted(() => vi.fn()); +const mockCanRelaunchForUpdate = vi.hoisted(() => vi.fn()); const mockGetInstallationInfo = vi.hoisted(() => vi.fn()); const mockUpdateEventEmit = vi.hoisted(() => vi.fn()); const mockConnectIdeForStartup = vi.hoisted(() => vi.fn()); @@ -33,6 +33,11 @@ const mockGetIdeClientInstance = vi.hoisted(() => ); const mockInitializeTelemetry = vi.hoisted(() => vi.fn()); const mockStartBackgroundHousekeeping = vi.hoisted(() => vi.fn()); +const mockPlatform = vi.hoisted(() => vi.fn()); + +vi.mock('node:os', () => ({ + default: { platform: mockPlatform }, +})); vi.mock('@qwen-code/qwen-code-core', () => ({ createDebugLogger: () => ({ @@ -60,7 +65,8 @@ vi.mock('../utils/processUtils.js', () => ({ CUSTOM_SANDBOX_IMAGE_ENV_VAR: 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE', HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', SKIP_UPDATE_CHECK_ENV_VAR: 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE', - requestUpdateOnExit: (...args: unknown[]) => mockRequestUpdateOnExit(...args), + canRelaunchForUpdate: (...args: unknown[]) => + mockCanRelaunchForUpdate(...args), })); vi.mock('../utils/handleAutoUpdate.js', () => ({ @@ -130,7 +136,8 @@ describe('startupPrefetch', () => { updateCommand: 'npm install -g @qwen-code/qwen-code@latest', isStandalone: false, }); - mockRequestUpdateOnExit.mockReturnValue(true); + mockCanRelaunchForUpdate.mockReturnValue(true); + mockPlatform.mockReturnValue('darwin'); mockConnectIdeForStartup.mockResolvedValue(undefined); mockDisconnectIde.mockResolvedValue(undefined); mockGetIdeClientInstance.mockResolvedValue({ @@ -209,7 +216,7 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); - expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); expect(mockRecordStartupEvent).toHaveBeenCalledWith( 'startup_prefetch_started', { name: 'update_check' }, @@ -220,7 +227,7 @@ describe('startupPrefetch', () => { ); }); - it('defers an available update until the session exits', async () => { + it('relaunches immediately when an automatic update is available', async () => { const config = makeConfig(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -234,15 +241,13 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { - message: - 'Update available\nThe update will be installed after you exit this session.', - }); + expect(mockCanRelaunchForUpdate).toHaveBeenCalledOnce(); + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); + expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); }); it('prompts for an explicit update when no parent supervisor is available', async () => { - mockRequestUpdateOnExit.mockReturnValue(false); + mockCanRelaunchForUpdate.mockReturnValue(false); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -259,7 +264,7 @@ describe('startupPrefetch', () => { }); }); - it('defers standalone updates until the session exits', async () => { + it('relaunches immediately for standalone updates', async () => { const config = makeConfig(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -278,11 +283,11 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); }); - it('keeps a container running until the user updates the host', async () => { + it('relaunches a container immediately when the host can update', async () => { process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = 'true'; mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -295,12 +300,34 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(makeConfig(), makeSettings()); await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); expect(mockGetInstallationInfo).not.toHaveBeenCalled(); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { - message: - 'Update available\nRun /update to install the update on the host.', + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); + }); + + it('stages Windows standalone updates without relaunching', async () => { + mockPlatform.mockReturnValue('win32'); + mockGetInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: 'C:\\qwen-code', }); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'update', + info: { + message: 'Update available', + update: { latest: '2.0.0' }, + }, + }); + + startPostRenderPrefetches(makeConfig(), makeSettings()); + await vi.dynamicImportSettled(); + + expect(mockHandleAutoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Update available' }), + expect.anything(), + '/repo', + ); + expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); }); it('keeps a container running when the host requires manual updates', async () => { @@ -316,7 +343,8 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(makeConfig(), makeSettings()); await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); + expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { message: @@ -346,7 +374,8 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); - expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); + expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); } finally { delete process.env['QWEN_CODE_CUSTOM_SANDBOX_IMAGE']; } @@ -397,7 +426,7 @@ describe('startupPrefetch', () => { message: 'Failed to check for updates. Please check your network or registry configuration.', }); - expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); }); it('requires connectIde option before connecting IDE', async () => { diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index cf92bba8b33..61b378e6b6b 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -11,6 +11,7 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; +import os from 'node:os'; import { preconnectApi } from '../utils/apiPreconnect.js'; import { AppEvent, appEvents } from '../utils/events.js'; import { recordStartupEvent } from '../utils/startupProfiler.js'; @@ -18,7 +19,7 @@ import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, - requestUpdateOnExit, + canRelaunchForUpdate, } from '../utils/processUtils.js'; const debugLogger = createDebugLogger('STARTUP_PREFETCH'); @@ -180,11 +181,7 @@ export function startPostRenderPrefetches( const projectRoot = config.getProjectRoot(); const hostUpdateRelaunch = process.env[HOST_UPDATE_RELAUNCH_ENV_VAR]; if (hostUpdateRelaunch === 'true') { - updateEventEmitter.emit('update-info', { - message: `${result.info.message}\n${t( - 'Run /update to install the update on the host.', - )}`, - }); + updateEventEmitter.emit('update-relaunch'); return; } if (hostUpdateRelaunch === 'false') { @@ -196,16 +193,20 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); + if ( + installationInfo.isStandalone && + installationInfo.standaloneDir && + os.platform() === 'win32' + ) { + void handleAutoUpdate(result.info, settings, projectRoot); + return; + } if ( installationInfo.updateCommand || (installationInfo.isStandalone && installationInfo.standaloneDir) ) { - if (requestUpdateOnExit()) { - updateEventEmitter.emit('update-info', { - message: `${result.info.message}\n${t( - 'The update will be installed after you exit this session.', - )}`, - }); + if (canRelaunchForUpdate()) { + updateEventEmitter.emit('update-relaunch'); } else { updateEventEmitter.emit('update-info', { message: `${result.info.message}\n${t( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 514cbf6cbb6..b43c23ebe60 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -187,6 +187,7 @@ import { migrateTomlCommands } from '../services/command-migration-tool.js'; import { sendNotification } from '../services/notificationService.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; +import { prepareUpdateRelaunch } from '../utils/processUtils.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; @@ -487,7 +488,12 @@ export const AppContainer = (props: AppContainerProps) => { const [themeError, setThemeError] = useState( initializationResult.themeError, ); - const [isProcessing, setIsProcessing] = useState(false); + const [isProcessing, setIsProcessingState] = useState(false); + const isProcessingRef = useRef(false); + const setIsProcessing = useCallback((value: boolean) => { + isProcessingRef.current = value; + setIsProcessingState(value); + }, []); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [geminiMdFileCount, setGeminiMdFileCount] = useState( @@ -1012,6 +1018,7 @@ export const AppContainer = (props: AppContainerProps) => { // Note: isIdleRef.current is assigned after streamingState becomes available // (see the assignment below useGeminiStream). const isIdleRef = useRef(true); + const hasDraftRef = useRef(false); // Live content-area height, kept in a ref so useGeminiStream (called above the // point where availableTerminalHeight is computed) can read the current value // when bounding the pending item's rendered height. terminalWidthRef pairs @@ -1023,16 +1030,6 @@ export const AppContainer = (props: AppContainerProps) => { flush: () => void; } | null>(null); - useEffect(() => { - const handler = setUpdateHandler( - historyManager.addItem, - setUpdateInfo, - isIdleRef, - ); - updateHandlerRef.current = handler; - return () => handler?.cleanup(); - }, [historyManager.addItem]); - // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = @@ -1062,6 +1059,7 @@ export const AppContainer = (props: AppContainerProps) => { shellModeActive, preferredEditor, }); + hasDraftRef.current = buffer.text.length > 0; const restoredPromptStashTargetsRef = useRef(new Set()); const promptStashTargetDir = config.getTargetDir(); useEffect(() => { @@ -1881,6 +1879,8 @@ export const AppContainer = (props: AppContainerProps) => { pendingToolCalls, streamingResponseLengthRef, isReceivingContent, + isSubmittingQueryRef, + hasPendingAutomaticSubmission, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1911,16 +1911,8 @@ export const AppContainer = (props: AppContainerProps) => { // Now that streamingState is available, keep isIdleRef in sync and // flush any deferred update notifications when the model finishes responding. - isIdleRef.current = streamingState === StreamingState.Idle; - - useEffect(() => { - if (streamingState === StreamingState.Idle) { - updateHandlerRef.current?.flush(); - // P7-trigger: a steered turn has finished — drop the `workflow active` - // indicator until the next keyword prompt re-arms it. - setWorkflowKeywordActive(false); - } - }, [streamingState]); + const isIdle = streamingState === StreamingState.Idle; + isIdleRef.current = isIdle; // Auto-open the skill-review dialog when idle and there are pending skills. // Gated on the live auto-skill flag: after the dialog's turn-off option @@ -2020,8 +2012,47 @@ export const AppContainer = (props: AppContainerProps) => { restoreMessages, drainQueue, popNextSegment, + hasQueuedMessages, } = useMessageQueue(); + useEffect(() => { + const handler = setUpdateHandler( + historyManager.addItem, + setUpdateInfo, + isIdleRef, + () => + isIdleRef.current && + !isSubmittingQueryRef.current && + !hasDraftRef.current && + !isProcessingRef.current && + !hasQueuedMessages() && + !hasPendingAutomaticSubmission(), + () => + prepareUpdateRelaunch( + config, + historyRef.current.some((item) => item.type === MessageType.USER), + initialPromptSubmitted.current, + ), + ); + updateHandlerRef.current = handler; + return () => handler?.cleanup(); + }, [ + config, + hasPendingAutomaticSubmission, + hasQueuedMessages, + historyManager.addItem, + isSubmittingQueryRef, + ]); + + useEffect(() => { + if (isIdle) { + updateHandlerRef.current?.flush(); + // P7-trigger: a steered turn has finished — drop the `workflow active` + // indicator until the next keyword prompt re-arms it. + setWorkflowKeywordActive(false); + } + }, [isIdle, buffer.text, isProcessing, messageQueue.length]); + // Bridge message queue to mid-turn drain via ref. // drainQueue reads the synchronous queueRef inside the hook, so it // stays consistent with popNextSegment even before React re-renders. diff --git a/packages/cli/src/ui/commands/update-command.test.ts b/packages/cli/src/ui/commands/update-command.test.ts index 181da9d286f..c755e685db5 100644 --- a/packages/cli/src/ui/commands/update-command.test.ts +++ b/packages/cli/src/ui/commands/update-command.test.ts @@ -4,11 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; const checkForUpdatesDetailed = vi.fn(); const relaunchForUpdate = vi.fn(); +const canRelaunchForUpdate = vi.fn(); +const prepareUpdateRelaunch = vi.fn(); const performStandaloneUpdate = vi.fn(); const getInstallationInfo = vi.fn(); const resolveUpdateCommand = vi.fn( @@ -40,6 +43,8 @@ vi.mock('../utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); vi.mock('../../utils/processUtils.js', () => ({ CUSTOM_SANDBOX_IMAGE_ENV_VAR: 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE', HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', + canRelaunchForUpdate, + prepareUpdateRelaunch, relaunchForUpdate, })); vi.mock('../../utils/standalone-update.js', () => ({ @@ -64,15 +69,25 @@ function context( }, config: { getProjectRoot: () => '/repo', + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + getQuestion: () => '', }, }, }); } describe('updateCommand', () => { + let platformSpy: ReturnType; + beforeEach(() => { vi.clearAllMocks(); relaunchForUpdate.mockReset(); + canRelaunchForUpdate.mockReturnValue(true); + prepareUpdateRelaunch.mockResolvedValue({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }); + platformSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin'); delete process.env['QWEN_CODE_CUSTOM_SANDBOX_IMAGE']; delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; checkForUpdatesDetailed.mockResolvedValue({ @@ -88,13 +103,20 @@ describe('updateCommand', () => { }); }); + afterEach(() => { + platformSpy.mockRestore(); + }); + it('hands an interactive update off to the parent process', async () => { const commandContext = context('interactive'); const result = await updateCommand.action!(commandContext, ''); expect(result).toBeUndefined(); - expect(relaunchForUpdate).toHaveBeenCalledTimes(1); + expect(relaunchForUpdate).toHaveBeenCalledWith( + '123e4567-e89b-12d3-a456-426614174000', + true, + ); expect( commandContext.services.settings.merged.general?.enableAutoUpdate, ).toBeUndefined(); @@ -164,6 +186,25 @@ describe('updateCommand', () => { expect(relaunchForUpdate).toHaveBeenCalledTimes(1); }); + it('stages a Windows standalone update without closing the session', async () => { + platformSpy.mockReturnValue('win32'); + getInstallationInfo.mockReturnValue({ + isStandalone: true, + standaloneDir: 'C:\\qwen-code', + }); + performStandaloneUpdate.mockResolvedValue('deferred'); + + const result = await updateCommand.action!(context('interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nDownloading update...\nUpdate downloaded. It will be applied after you exit this session.', + }); + expect(relaunchForUpdate).not.toHaveBeenCalled(); + }); + it('does not mutate enableAutoUpdate when relaunching throws', async () => { const commandContext = context('interactive'); relaunchForUpdate.mockImplementation(() => { @@ -194,6 +235,34 @@ describe('updateCommand', () => { expect(relaunchForUpdate).not.toHaveBeenCalled(); }); + it('falls back to manual guidance without a stable launcher', async () => { + canRelaunchForUpdate.mockReturnValue(false); + + const result = await updateCommand.action!(context('interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', + }); + expect(relaunchForUpdate).not.toHaveBeenCalled(); + }); + + it('keeps a non-resumable interactive session open', async () => { + prepareUpdateRelaunch.mockResolvedValue(null); + + const result = await updateCommand.action!(context('interactive'), ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', + }); + expect(relaunchForUpdate).not.toHaveBeenCalled(); + }); + it('returns the manual update command in ACP mode', async () => { const result = await updateCommand.action!(context('acp'), ''); diff --git a/packages/cli/src/ui/commands/update-command.ts b/packages/cli/src/ui/commands/update-command.ts index 9cc9c0d0737..ee0ebd5397b 100644 --- a/packages/cli/src/ui/commands/update-command.ts +++ b/packages/cli/src/ui/commands/update-command.ts @@ -7,6 +7,7 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +import os from 'node:os'; export const updateCommand: SlashCommand = { name: 'update', @@ -21,6 +22,8 @@ export const updateCommand: SlashCommand = { { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, + canRelaunchForUpdate, + prepareUpdateRelaunch, relaunchForUpdate, }, { performStandaloneUpdate }, @@ -34,7 +37,8 @@ export const updateCommand: SlashCommand = { const { formatUpdateInstructions, getInstallationInfo } = installationInfo; const settings = context.services.settings; - const projectRoot = context.services.config?.getProjectRoot(); + const config = context.services.config; + const projectRoot = config?.getProjectRoot(); const updateCheck = await checkForUpdatesDetailed(); @@ -84,8 +88,38 @@ export const updateCommand: SlashCommand = { content: lines.join('\n'), }; }; + const updateStandalone = async () => { + try { + const result = await performStandaloneUpdate( + installInfo.standaloneDir!, + info.update.latest, + ); + const message = + result === 'done' + ? t( + 'Update successful! The new version will be used on your next run.', + ) + : t( + 'Update downloaded. It will be applied after you exit this session.', + ); + return { + type: 'message' as const, + messageType: 'info' as const, + content: `${info.message}\n${t('Downloading update...')}\n${message}`, + }; + } catch (err) { + const message = t('Update failed: {{error}}', { + error: err instanceof Error ? err.message : String(err), + }); + return { + type: 'message' as const, + messageType: 'error' as const, + content: `${info.message}\n${message}`, + }; + } + }; - if (context.executionMode === 'interactive' && projectRoot) { + if (context.executionMode === 'interactive' && projectRoot && config) { const customSandboxImage = process.env[CUSTOM_SANDBOX_IMAGE_ENV_VAR]; if (customSandboxImage) { return { @@ -101,7 +135,13 @@ export const updateCommand: SlashCommand = { const isAutoUpdateEnabled = settings.merged.general?.enableAutoUpdate !== false; if (hostUpdateRelaunch === 'true' && isAutoUpdateEnabled) { - await relaunchForUpdate(); + const prepared = await prepareUpdateRelaunch( + config, + context.ui.history.some((item) => item.type === 'user'), + Boolean(config.getQuestion()), + ); + if (!prepared) return manualInstructions(); + await relaunchForUpdate(prepared.sessionId, prepared.skipInitialPrompt); return; } if (hostUpdateRelaunch !== undefined) { @@ -116,42 +156,28 @@ export const updateCommand: SlashCommand = { const canAutoUpdate = installInfo.updateCommand || (installInfo.isStandalone && installInfo.standaloneDir); - if (isAutoUpdateEnabled && canAutoUpdate) { - await relaunchForUpdate(); + if ( + installInfo.isStandalone && + installInfo.standaloneDir && + os.platform() === 'win32' + ) { + return updateStandalone(); + } + if (isAutoUpdateEnabled && canAutoUpdate && canRelaunchForUpdate()) { + const prepared = await prepareUpdateRelaunch( + config, + context.ui.history.some((item) => item.type === 'user'), + Boolean(config.getQuestion()), + ); + if (!prepared) return manualInstructions(); + await relaunchForUpdate(prepared.sessionId, prepared.skipInitialPrompt); return; } return manualInstructions(); } if (installInfo.isStandalone && installInfo.standaloneDir) { - try { - const result = await performStandaloneUpdate( - installInfo.standaloneDir, - info.update.latest, - ); - const message = - result === 'done' - ? t( - 'Update successful! The new version will be used on your next run.', - ) - : t( - 'Update downloaded. It will be applied after you exit this session.', - ); - return { - type: 'message' as const, - messageType: 'info' as const, - content: `${info.message}\n${t('Downloading update...')}\n${message}`, - }; - } catch (err) { - const message = t('Update failed: {{error}}', { - error: err instanceof Error ? err.message : String(err), - }); - return { - type: 'message' as const, - messageType: 'error' as const, - content: `${info.message}\n${message}`, - }; - } + return updateStandalone(); } // Non-interactive / ACP mode: report the available update and manual command. diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 61beb142c10..7f67d6fb71e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -396,7 +396,7 @@ describe('useGeminiStream', () => { }; it('queues background shell terminal notifications for the model loop', async () => { - const { mockSendMessageStream } = renderTestHook(); + const { mockSendMessageStream, result } = renderTestHook(); const displayText = 'Background shell "npm test" completed.'; const modelText = '\nshell\ncompleted\n'; @@ -412,6 +412,7 @@ describe('useGeminiStream', () => { act(() => { callback(displayText, modelText); + expect(result.current.hasPendingAutomaticSubmission()).toBe(true); }); await waitFor(() => { @@ -431,6 +432,7 @@ describe('useGeminiStream', () => { }), ); }); + expect(result.current.hasPendingAutomaticSubmission()).toBe(false); }); describe('vision bridge gate', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 2be5bb40dbf..d51e4a73b85 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3893,6 +3893,13 @@ export const useGeminiStream = ( } }, [streamingState, submitQuery, teammateTrigger, addItem]); + const hasPendingAutomaticSubmission = useCallback( + () => + notificationQueueRef.current.length > 0 || + teammateQueueRef.current.length > 0, + [], + ); + return { streamingState, submitQuery, @@ -3907,5 +3914,7 @@ export const useGeminiStream = ( loopDetectionConfirmationRequest, streamingResponseLengthRef, isReceivingContent, + isSubmittingQueryRef, + hasPendingAutomaticSubmission, }; }; diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index 05ff3f0ba5b..52a56f468a7 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -23,6 +23,7 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual([]); expect(result.current.getQueuedMessagesText()).toBe(''); + expect(result.current.hasQueuedMessages()).toBe(false); }); it('should add messages to queue', () => { @@ -37,6 +38,7 @@ describe('useMessageQueue', () => { 'Test message 1', 'Test message 2', ]); + expect(result.current.hasQueuedMessages()).toBe(true); }); it('should filter out empty messages', () => { @@ -69,6 +71,7 @@ describe('useMessageQueue', () => { }); expect(result.current.messageQueue).toEqual([]); + expect(result.current.hasQueuedMessages()).toBe(false); }); it('should return queued messages as text with double newlines', () => { diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index 448d49e7f95..e4ee3df9ac1 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -24,6 +24,7 @@ export interface UseMessageQueueReturn { drainQueue: (includeDeferred?: boolean) => string[]; /** Pop the first item from the queue. */ popNextSegment: () => string | null; + hasQueuedMessages: () => boolean; } interface QueuedMessage { @@ -97,6 +98,7 @@ export function useMessageQueue(): UseMessageQueueReturn { setQueuedMessages(rest); return head.text; }, []); + const hasQueuedMessages = useCallback(() => queueRef.current.length > 0, []); return { messageQueue: queuedMessages.map(({ text }) => text), @@ -107,5 +109,6 @@ export function useMessageQueue(): UseMessageQueueReturn { restoreMessages, drainQueue, popNextSegment, + hasQueuedMessages, }; } diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 94a2184bea7..3a9990a44b7 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -16,6 +16,8 @@ import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; import os from 'node:os'; +const mockRelaunchForUpdate = vi.hoisted(() => vi.fn()); + vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); return { @@ -35,6 +37,10 @@ vi.mock('./updateEventEmitter.js', async () => { }; }); +vi.mock('./processUtils.js', () => ({ + relaunchForUpdate: (...args: unknown[]) => mockRelaunchForUpdate(...args), +})); + interface MockChildProcess extends EventEmitter { stdin: EventEmitter & { write: Mock; @@ -421,6 +427,7 @@ describe('setUpdateHandler', () => { beforeEach(() => { addItem = vi.fn(); setUpdateInfo = vi.fn(); + mockRelaunchForUpdate.mockReset().mockResolvedValue(undefined); updateEventEmitter.removeAllListeners(); }); @@ -483,6 +490,151 @@ describe('setUpdateHandler', () => { cleanup(); }); + it('relaunches immediately when idle', async () => { + const isIdleRef = { current: true }; + const { cleanup } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + () => true, + () => ({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }), + ); + + updateEventEmitter.emit('update-relaunch'); + + await vi.waitFor(() => + expect(mockRelaunchForUpdate).toHaveBeenCalledWith( + '123e4567-e89b-12d3-a456-426614174000', + true, + ), + ); + cleanup(); + }); + + it('waits for an unsent draft to be cleared before relaunching', async () => { + const isIdleRef = { current: true }; + const canRelaunchRef = { current: false }; + const { cleanup, flush } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + () => canRelaunchRef.current, + () => ({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }), + ); + + updateEventEmitter.emit('update-relaunch'); + expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); + + canRelaunchRef.current = true; + flush(); + + await vi.waitFor(() => + expect(mockRelaunchForUpdate).toHaveBeenCalledWith( + '123e4567-e89b-12d3-a456-426614174000', + true, + ), + ); + cleanup(); + }); + + it('keeps a non-resumable session open', async () => { + const isIdleRef = { current: true }; + const { cleanup } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + () => true, + () => null, + ); + + updateEventEmitter.emit('update-relaunch'); + + await vi.waitFor(() => + expect(addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: 'Run /update to install the update.', + }, + expect.any(Number), + ), + ); + expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); + cleanup(); + }); + + it('rechecks safety after preparing the relaunch', async () => { + let resolvePreparation!: (value: { sessionId: string }) => void; + const preparation = new Promise<{ sessionId: string }>((resolve) => { + resolvePreparation = resolve; + }); + let safe = true; + const { cleanup, flush } = setUpdateHandler( + addItem, + setUpdateInfo, + { current: true }, + () => safe, + () => preparation, + ); + + updateEventEmitter.emit('update-relaunch'); + safe = false; + resolvePreparation({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + }); + await Promise.resolve(); + await Promise.resolve(); + expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); + + safe = true; + flush(); + await vi.waitFor(() => + expect(mockRelaunchForUpdate).toHaveBeenCalledOnce(), + ); + cleanup(); + }); + + it('defers relaunch until the active turn becomes idle', async () => { + const isIdleRef = { current: false }; + const { cleanup, flush } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + ); + + updateEventEmitter.emit('update-relaunch'); + expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); + + isIdleRef.current = true; + flush(); + + await vi.waitFor(() => + expect(mockRelaunchForUpdate).toHaveBeenCalledOnce(), + ); + cleanup(); + }); + + it('cancels a deferred relaunch during cleanup', () => { + const isIdleRef = { current: false }; + const { cleanup, flush } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + ); + + updateEventEmitter.emit('update-relaunch'); + cleanup(); + isIdleRef.current = true; + flush(); + + expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); + }); + it('should defer addItem when not idle (update-success)', () => { const isIdleRef = { current: false }; const { cleanup } = setUpdateHandler(addItem, setUpdateInfo, isIdleRef); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index c302b48acc9..8c7560a3fd9 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -21,6 +21,7 @@ import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; import os from 'node:os'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { relaunchForUpdate } from './processUtils.js'; const debugLogger = createDebugLogger('AUTO_UPDATE'); @@ -148,8 +149,15 @@ export function setUpdateHandler( addItem: (item: HistoryItemWithoutId, timestamp: number) => void, setUpdateInfo: (info: UpdateObject | null) => void, isIdleRef: { current: boolean } = { current: true }, + canRelaunch: () => boolean = () => isIdleRef.current, + prepareRelaunch: () => + | Promise<{ sessionId?: string; skipInitialPrompt?: boolean } | null> + | { sessionId?: string; skipInitialPrompt?: boolean } + | null = () => ({}), ) { let successfullyInstalled = false; + let relaunchPending = false; + let disposed = false; const pendingNotifications: HistoryItemWithoutId[] = []; const addItemOrDefer = (item: HistoryItemWithoutId) => { @@ -198,17 +206,52 @@ export function setUpdateHandler( }); }; + const runUpdateRelaunch = () => { + relaunchPending = false; + void Promise.resolve(prepareRelaunch()) + .then((prepared) => { + if (disposed) return; + if (!canRelaunch()) { + relaunchPending = true; + return; + } + if (!prepared) { + handleUpdateInfo({ + message: t('Run /update to install the update.'), + }); + return; + } + return relaunchForUpdate( + prepared.sessionId, + prepared.skipInitialPrompt, + ); + }) + .catch(() => handleUpdateFailed()); + }; + + const handleUpdateRelaunch = () => { + if (canRelaunch()) { + runUpdateRelaunch(); + } else { + relaunchPending = true; + } + }; + updateEventEmitter.on('update-received', handleUpdateReceived); updateEventEmitter.on('update-failed', handleUpdateFailed); updateEventEmitter.on('update-success', handleUpdateSuccess); updateEventEmitter.on('update-info', handleUpdateInfo); + updateEventEmitter.on('update-relaunch', handleUpdateRelaunch); const cleanup = () => { + disposed = true; updateEventEmitter.off('update-received', handleUpdateReceived); updateEventEmitter.off('update-failed', handleUpdateFailed); updateEventEmitter.off('update-success', handleUpdateSuccess); updateEventEmitter.off('update-info', handleUpdateInfo); + updateEventEmitter.off('update-relaunch', handleUpdateRelaunch); pendingNotifications.length = 0; + relaunchPending = false; }; const flush = () => { @@ -216,6 +259,9 @@ export function setUpdateHandler( const item = pendingNotifications.shift()!; addItem(item, Date.now()); } + if (relaunchPending && canRelaunch()) { + runUpdateRelaunch(); + } }; return { cleanup, flush }; diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts index aff233662b3..47bb239c29d 100644 --- a/packages/cli/src/utils/processUtils.test.ts +++ b/packages/cli/src/utils/processUtils.test.ts @@ -5,15 +5,21 @@ */ import { afterEach, beforeEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { RELAUNCH_EXIT_CODE, - UPDATE_ON_EXIT_MESSAGE, + UPDATE_RELAUNCH_STATE_PATH_ENV_VAR, UPDATE_RELAUNCH_EXIT_CODE, + UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, + canRelaunchForUpdate, + prepareUpdateRelaunch, relaunchApp, relaunchForUpdate, - requestUpdateOnExit, } from './processUtils.js'; import * as cleanup from './cleanup.js'; +import type { Config } from '@qwen-code/qwen-code-core'; describe('processUtils', () => { const processExit = vi @@ -21,13 +27,27 @@ describe('processUtils', () => { .mockReturnValue(undefined as never); const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup'); const originalSend = process.send; + const originalSupported = process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; + const originalStatePath = process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; beforeEach(() => { vi.clearAllMocks(); + delete process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; + delete process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; }); afterEach(() => { process.send = originalSend; + if (originalSupported === undefined) { + delete process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; + } else { + process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = originalSupported; + } + if (originalStatePath === undefined) { + delete process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; + } else { + process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = originalStatePath; + } }); it('should run cleanup and exit with the relaunch code', async () => { @@ -42,17 +62,71 @@ describe('processUtils', () => { expect(processExit).toHaveBeenCalledWith(UPDATE_RELAUNCH_EXIT_CODE); }); - it('requests a deferred update from the parent process', () => { - const send = vi.fn(); - process.send = send; + it('detects an update relaunch supervisor', () => { + process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = 'true'; + process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = '/tmp/relaunch.json'; - expect(requestUpdateOnExit()).toBe(true); - expect(send).toHaveBeenCalledWith({ type: UPDATE_ON_EXIT_MESSAGE }); + expect(canRelaunchForUpdate()).toBe(true); }); - it('does not request a deferred update without a parent process', () => { - process.send = undefined; + it('does not infer a supervisor from IPC alone', () => { + process.send = vi.fn(); - expect(requestUpdateOnExit()).toBe(false); + expect(canRelaunchForUpdate()).toBe(false); + }); + + it('writes the resumed session before exiting for an update', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-state-')); + const statePath = path.join(dir, 'state.json'); + process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = 'true'; + process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = statePath; + + try { + await relaunchForUpdate('123e4567-e89b-12d3-a456-426614174000'); + + expect(JSON.parse(fs.readFileSync(statePath, 'utf8'))).toEqual({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }); + expect(processExit).toHaveBeenCalledWith(UPDATE_RELAUNCH_EXIT_CODE); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('only resumes sessions that have a durable transcript', async () => { + const getSessionLocation = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('active'); + const config = { + getChatRecordingService: () => ({ flush: vi.fn() }), + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + getSessionService: () => ({ getSessionLocation }), + } as unknown as Config; + + await expect(prepareUpdateRelaunch(config, false)).resolves.toEqual({ + skipInitialPrompt: false, + }); + await expect(prepareUpdateRelaunch(config, true)).resolves.toEqual({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }); + getSessionLocation.mockResolvedValueOnce(undefined); + await expect(prepareUpdateRelaunch(config, true)).resolves.toBeNull(); + }); + + it('preserves whether a fresh initial prompt was already consumed', async () => { + const config = { + getChatRecordingService: () => ({ flush: vi.fn() }), + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + getSessionService: () => ({ + getSessionLocation: vi.fn().mockResolvedValue(undefined), + }), + } as unknown as Config; + + await expect(prepareUpdateRelaunch(config, false, true)).resolves.toEqual({ + skipInitialPrompt: true, + }); }); }); diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts index a838155dd6a..125ab522c73 100644 --- a/packages/cli/src/utils/processUtils.ts +++ b/packages/cli/src/utils/processUtils.ts @@ -5,6 +5,8 @@ */ import { runExitCleanup } from './cleanup.js'; +import fs from 'node:fs'; +import type { Config } from '@qwen-code/qwen-code-core'; /** * Exit code used to signal that the CLI should be relaunched. @@ -17,11 +19,17 @@ export const UPDATE_COMPLETE_EXIT_CODE = 44; export const SKIP_UPDATE_CHECK_ENV_VAR = 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE'; +export const SKIP_INITIAL_PROMPT_ENV_VAR = 'QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE'; + export const CUSTOM_SANDBOX_IMAGE_ENV_VAR = 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE'; export const HOST_UPDATE_RELAUNCH_ENV_VAR = 'QWEN_CODE_HOST_UPDATE_RELAUNCH'; -export const UPDATE_ON_EXIT_MESSAGE = 'qwen-code:update-on-exit'; +export const UPDATE_RELAUNCH_SUPPORTED_ENV_VAR = + 'QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'; + +export const UPDATE_RELAUNCH_STATE_PATH_ENV_VAR = + 'QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'; /** * Exits the process with a special code to signal that the parent process should relaunch it. @@ -31,17 +39,45 @@ export async function relaunchApp(): Promise { process.exit(RELAUNCH_EXIT_CODE); } -export async function relaunchForUpdate(): Promise { +export async function relaunchForUpdate( + sessionId?: string, + skipInitialPrompt = Boolean(sessionId), +): Promise { + const statePath = process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; + if (statePath && (sessionId || skipInitialPrompt)) { + fs.writeFileSync( + statePath, + JSON.stringify({ sessionId, skipInitialPrompt }), + { + encoding: 'utf8', + mode: 0o600, + }, + ); + } await runExitCleanup(); process.exit(UPDATE_RELAUNCH_EXIT_CODE); } -export function requestUpdateOnExit(): boolean { - if (!process.send) return false; +export function canRelaunchForUpdate(): boolean { + return ( + process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] === 'true' && + Boolean(process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]) + ); +} + +export async function prepareUpdateRelaunch( + config: Config, + hasUserMessages: boolean, + initialPromptConsumed = false, +): Promise<{ sessionId?: string; skipInitialPrompt: boolean } | null> { try { - process.send({ type: UPDATE_ON_EXIT_MESSAGE }); - return true; + await config.getChatRecordingService()?.flush(); + const sessionId = config.getSessionId(); + if (await config.getSessionService().getSessionLocation(sessionId)) { + return { sessionId, skipInitialPrompt: true }; + } } catch { - return false; + if (hasUserMessages) return null; } + return hasUserMessages ? null : { skipInitialPrompt: initialPromptConsumed }; } diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index bfeda18c7b8..7ffa68f2809 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -16,7 +16,6 @@ import { import { EventEmitter } from 'node:events'; import { RELAUNCH_EXIT_CODE, - UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, } from './processUtils.js'; import type { ChildProcess } from 'node:child_process'; @@ -93,7 +92,7 @@ describe('relaunchOnExitCode', () => { relaunchOnExitCode(runner, { onUpdateRelaunch }), ).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).toHaveBeenCalledWith(true); + expect(onUpdateRelaunch).toHaveBeenCalledOnce(); expect(runner).toHaveBeenCalledTimes(1); expect(processExitSpy).toHaveBeenCalledWith(0); }); @@ -337,7 +336,7 @@ describe('relaunchAppInChildProcess', () => { expect(afterSpawn).toHaveBeenCalledTimes(1); }); - it('installs a requested automatic update only after a clean child exit', async () => { + it('does not update or relaunch after a clean child exit', async () => { process.argv = ['/usr/bin/node', '/app/cli.js']; const onUpdateRelaunch = vi.fn().mockResolvedValue(44); @@ -348,37 +347,9 @@ describe('relaunchAppInChildProcess', () => { onUpdateRelaunch, }); - mockChild.emit('message', { type: UPDATE_ON_EXIT_MESSAGE }); - expect(onUpdateRelaunch).not.toHaveBeenCalled(); - mockChild.emit('close', 0); await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).toHaveBeenCalledWith(false); - expect(processExitSpy).toHaveBeenCalledWith(44); - }); - - it('does not carry an update request across relaunches', async () => { - process.argv = ['/usr/bin/node', '/app/cli.js']; - - const onUpdateRelaunch = vi.fn().mockResolvedValue(44); - const firstChild = createMockChildProcess(0, false); - const secondChild = createMockChildProcess(0, false); - mockedSpawn - .mockReturnValueOnce(firstChild) - .mockReturnValueOnce(secondChild); - - const promise = relaunchAppInChildProcess([], [], { - onUpdateRelaunch, - }); - - firstChild.emit('message', { type: UPDATE_ON_EXIT_MESSAGE }); - firstChild.emit('close', RELAUNCH_EXIT_CODE); - await vi.waitFor(() => expect(mockedSpawn).toHaveBeenCalledTimes(2)); - - secondChild.emit('close', 0); - await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).not.toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index e4572d1360a..cd2d5c9e766 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -7,14 +7,13 @@ import { spawn } from 'node:child_process'; import { RELAUNCH_EXIT_CODE, - UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, } from './processUtils.js'; import { writeStderrLine } from './stdioHelpers.js'; interface RelaunchOptions { afterSpawn?: () => void; - onUpdateRelaunch?: (relaunchOnFailure: boolean) => Promise | number; + onUpdateRelaunch?: () => Promise | number; } export async function relaunchOnExitCode( @@ -26,7 +25,7 @@ export async function relaunchOnExitCode( const exitCode = await runner(); if (exitCode === UPDATE_RELAUNCH_EXIT_CODE && options?.onUpdateRelaunch) { - const updatedExitCode = await options.onUpdateRelaunch(true); + const updatedExitCode = await options.onUpdateRelaunch(); process.exit(updatedExitCode); } @@ -52,8 +51,6 @@ export async function relaunchAppInChildProcess( } const runner = () => { - let updateOnExitRequested = false; - // process.argv is [node, script, ...args] // We want to construct [ ...nodeArgs, script, ...scriptArgs] const script = process.argv[1]; @@ -78,21 +75,10 @@ export async function relaunchAppInChildProcess( process.stdin.pause(); const child = spawn(process.execPath, nodeArgs, { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + stdio: 'inherit', env: newEnv, }); - child.on('message', (message) => { - if ( - typeof message === 'object' && - message !== null && - 'type' in message && - message.type === UPDATE_ON_EXIT_MESSAGE - ) { - updateOnExitRequested = true; - } - }); - // Allow the parent to clean up process.env after spawn copies it // but before the next relaunch iteration. try { @@ -107,20 +93,7 @@ export async function relaunchAppInChildProcess( child.on('close', (code) => { // Resume stdin before the parent process exits. process.stdin.resume(); - const exitCode = code ?? 1; - if ( - exitCode === 0 && - updateOnExitRequested && - options?.onUpdateRelaunch - ) { - updateOnExitRequested = false; - void Promise.resolve(options.onUpdateRelaunch(false)).then( - (updatedExitCode) => resolve(updatedExitCode), - reject, - ); - return; - } - resolve(exitCode); + resolve(code ?? 1); }); }); }; diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index 71bb6ff7443..f19de795a6f 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -135,16 +135,25 @@ describe('getSandboxPassthroughEnvArgs', () => { expect( getSandboxPassthroughEnvArgs({ QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', + QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE: 'true', QWEN_CODE_CUSTOM_SANDBOX_IMAGE: 'example.com/qwen:1.0.0', QWEN_CODE_HOST_UPDATE_RELAUNCH: 'false', + QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true', + QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: '/tmp/relaunch/state.json', }), ).toEqual([ + '--env', + 'QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE=true', '--env', 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE=true', '--env', 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE=example.com/qwen:1.0.0', '--env', 'QWEN_CODE_HOST_UPDATE_RELAUNCH=false', + '--env', + 'QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED=true', + '--env', + 'QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH=/tmp/relaunch/state.json', ]); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 1e8b4c5c25c..bdb8afba6ff 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -29,7 +29,10 @@ import { parseSandboxMountSpec } from './sandboxMounts.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, + SKIP_INITIAL_PROMPT_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, + UPDATE_RELAUNCH_STATE_PATH_ENV_VAR, + UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, } from './processUtils.js'; const execAsync = promisify(exec); @@ -69,15 +72,25 @@ const BUILTIN_SEATBELT_PROFILES = [ export function getSandboxPassthroughEnvArgs( env: NodeJS.ProcessEnv = process.env, ): string[] { - return [ + const args = [ 'QWEN_DEBUG_LOG_FILE', 'QWEN_CODE_LEGACY_MCP_BLOCKING', + SKIP_INITIAL_PROMPT_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, + UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, ].flatMap((envVar) => env[envVar] === undefined ? [] : ['--env', `${envVar}=${env[envVar]}`], ); + const statePath = env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; + if (statePath) { + args.push( + '--env', + `${UPDATE_RELAUNCH_STATE_PATH_ENV_VAR}=${getContainerPath(statePath)}`, + ); + } + return args; } export function resolveSeatbeltProfileFile( diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index 557bdb9823c..d4ee11f6ac9 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -46,7 +46,7 @@ describe('updateBeforeRelaunch', () => { const updateProcess = new EventEmitter(); handleAutoUpdate.mockReturnValue(updateProcess); - const update = updateBeforeRelaunch(settings, '/repo', false); + const update = updateBeforeRelaunch(settings, '/repo'); await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); expect(writeStderrLine).toHaveBeenCalledWith('Update available'); expect(writeStderrLine).not.toHaveBeenCalledWith( @@ -61,32 +61,24 @@ describe('updateBeforeRelaunch', () => { ); }); - it.each([ - ['explicit update', true, true], - ['background update-on-exit', false, false], - ] as const)( - 'reports %s failure and returns %s', - async (_source, relaunchOnFailure, expected) => { - const updateProcess = new EventEmitter(); - handleAutoUpdate.mockReturnValue(updateProcess); - - const update = updateBeforeRelaunch(settings, '/repo', relaunchOnFailure); - await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); - updateProcess.emit('close', 1); - await expect(update).resolves.toBe(expected); - - expect(writeStderrLine).toHaveBeenCalledWith( - 'Automatic update failed. Please try updating manually.', - ); - }, - ); + it('reports update failure and relaunches the old version', async () => { + const updateProcess = new EventEmitter(); + handleAutoUpdate.mockReturnValue(updateProcess); + + const update = updateBeforeRelaunch(settings, '/repo'); + await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); + updateProcess.emit('close', 1); + await expect(update).resolves.toBe(true); + + expect(writeStderrLine).toHaveBeenCalledWith( + 'Automatic update failed. Please try updating manually.', + ); + }); it('relaunches the old version when the update check fails', async () => { checkForUpdatesDetailed.mockResolvedValue({ status: 'error' }); - await expect(updateBeforeRelaunch(settings, '/repo', true)).resolves.toBe( - true, - ); + await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(true); expect(writeStderrLine).toHaveBeenCalledWith( 'Failed to check for updates. Please check your network or registry configuration.', ); @@ -104,7 +96,7 @@ describe('updateBeforeRelaunch', () => { }), ); - const update = updateBeforeRelaunch(settings, '/repo', false); + const update = updateBeforeRelaunch(settings, '/repo'); await vi.waitFor(() => expect(performStandaloneUpdate).toHaveBeenCalledWith('/qwen', '2.0.0'), ); @@ -121,9 +113,7 @@ describe('updateBeforeRelaunch', () => { }); performStandaloneUpdate.mockResolvedValue('deferred'); - await expect(updateBeforeRelaunch(settings, '/repo', false)).resolves.toBe( - false, - ); + await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(false); expect(writeStderrLine).toHaveBeenCalledWith( 'Update downloaded. It will be applied after you exit this session.', ); diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index d618888b8dd..f8a9430878d 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -15,7 +15,6 @@ const UPDATE_FAILED_MESSAGE = export async function updateBeforeRelaunch( settings: LoadedSettings, projectRoot: string, - relaunchOnFailure: boolean, ): Promise { let translate = (message: string) => message; try { @@ -57,7 +56,7 @@ export async function updateBeforeRelaunch( installationInfo.updateMessage ?? t('Manual update required. Please reinstall Qwen Code.'), ); - return relaunchOnFailure; + return true; } const updateProcess = handleAutoUpdate( result.info, @@ -77,12 +76,12 @@ export async function updateBeforeRelaunch( : UPDATE_FAILED_MESSAGE, ), ); - return success || relaunchOnFailure; + return true; } else if (result.status === 'error') { writeStderrLine(t(UPDATE_CHECK_FAILED_MESSAGE)); } } catch { writeStderrLine(translate(UPDATE_FAILED_MESSAGE)); } - return relaunchOnFailure; + return true; } diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index f4178b6b60c..9f89d7de29f 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -40,6 +40,40 @@ function hasFlag(flag, alias) { return false; } +function withResumeSession(args, sessionId) { + const result = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '--') { + return [...result, '--resume', sessionId, ...args.slice(i)]; + } + if (arg === '--continue' || arg === '-c' || arg.startsWith('--continue=')) { + continue; + } + const sessionFlag = + arg === '--resume' || + arg === '-r' || + arg === '--session-id' || + arg === '--sandbox-session-id'; + if (sessionFlag) { + if (args[i + 1] && args[i + 1] !== '--' && !args[i + 1].startsWith('-')) { + i++; + } + continue; + } + if ( + arg.startsWith('--resume=') || + arg.startsWith('--session-id=') || + arg.startsWith('--sandbox-session-id=') + ) { + continue; + } + result.push(arg); + } + result.push('--resume', sessionId); + return result; +} + function isInProcessFastPath() { const first = cliArgs[0]; if (first === 'serve' || first === 'mcp') { @@ -60,7 +94,9 @@ if (isTopLevelVersion && process.env['CLI_VERSION']) { process.exit(0); } -const { existsSync, realpathSync } = await import('node:fs'); +const { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } = + await import('node:fs'); +const { tmpdir } = await import('node:os'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( 'node:path' @@ -180,11 +216,41 @@ if (isInProcessFastPath()) { ...process.env, QWEN_CODE_LAUNCHER_PID: String(process.pid), }; + const relaunchStateDir = launcher + ? mkdtempSync(join(tmpdir(), 'qwen-code-relaunch-')) + : undefined; + const relaunchStatePath = relaunchStateDir + ? join(relaunchStateDir, 'state.json') + : undefined; + if (relaunchStatePath) { + env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED = 'true'; + env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH = relaunchStatePath; + } const result = spawnSync( process.execPath, ['--expose-gc', cliPath, ...cliArgs], { stdio: 'inherit', env }, ); + let relaunchSessionId; + let skipInitialPrompt = false; + if ( + result.status === UPDATE_COMPLETE_EXIT_CODE && + relaunchStatePath && + existsSync(relaunchStatePath) + ) { + try { + const state = JSON.parse(readFileSync(relaunchStatePath, 'utf8')); + if (typeof state.sessionId === 'string') { + relaunchSessionId = state.sessionId; + } + skipInitialPrompt = state.skipInitialPrompt === true; + } catch { + // Relaunch with the original arguments if the handoff state is invalid. + } + } + if (relaunchStateDir) { + rmSync(relaunchStateDir, { recursive: true, force: true }); + } if (result.signal) { process.kill(process.pid, result.signal); @@ -199,7 +265,14 @@ if (isInProcessFastPath()) { } const relaunchEnv = { ...process.env, - QWEN_CODE_RELAUNCH_ARGS: JSON.stringify(cliArgs), + QWEN_CODE_RELAUNCH_ARGS: JSON.stringify( + relaunchSessionId + ? withResumeSession(cliArgs, relaunchSessionId) + : cliArgs, + ), + ...(relaunchSessionId || skipInitialPrompt + ? { QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE: 'true' } + : {}), QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', }; const relaunchResult = From 47b4a63eef3211e61c46506a7dda50d127206e15 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 19 Jul 2026 19:44:04 +0800 Subject: [PATCH 4/7] Revert "fix(cli): restart safely for automatic updates" This reverts commit 5c5b529a769b87d675c1449af3ba4ff5b5fbf4df. --- docs/design/immediate-auto-update-relaunch.md | 36 ----- packages/cli/src/cli.test.ts | 132 +-------------- packages/cli/src/gemini.test.tsx | 76 +-------- packages/cli/src/gemini.tsx | 24 +-- packages/cli/src/i18n/locales/ca.js | 4 + packages/cli/src/i18n/locales/de.js | 4 + packages/cli/src/i18n/locales/en.js | 4 + packages/cli/src/i18n/locales/fr.js | 4 + packages/cli/src/i18n/locales/ja.js | 4 + packages/cli/src/i18n/locales/pt.js | 4 + packages/cli/src/i18n/locales/ru.js | 4 + packages/cli/src/i18n/locales/zh-TW.js | 4 + packages/cli/src/i18n/locales/zh.js | 4 + .../cli/src/startup/startup-prefetch.test.ts | 71 +++----- packages/cli/src/startup/startup-prefetch.ts | 25 ++- packages/cli/src/ui/AppContainer.tsx | 73 +++------ .../src/ui/commands/update-command.test.ts | 73 +-------- .../cli/src/ui/commands/update-command.ts | 92 ++++------- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 4 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 9 -- .../cli/src/ui/hooks/useMessageQueue.test.ts | 3 - packages/cli/src/ui/hooks/useMessageQueue.ts | 3 - .../cli/src/utils/handleAutoUpdate.test.ts | 152 ------------------ packages/cli/src/utils/handleAutoUpdate.ts | 46 ------ packages/cli/src/utils/processUtils.test.ts | 94 ++--------- packages/cli/src/utils/processUtils.ts | 50 +----- packages/cli/src/utils/relaunch.test.ts | 33 +++- packages/cli/src/utils/relaunch.ts | 35 +++- packages/cli/src/utils/sandbox.test.ts | 9 -- packages/cli/src/utils/sandbox.ts | 15 +- .../cli/src/utils/update-relaunch.test.ts | 44 +++-- packages/cli/src/utils/update-relaunch.ts | 7 +- scripts/cli-entry.js | 77 +-------- 33 files changed, 249 insertions(+), 970 deletions(-) delete mode 100644 docs/design/immediate-auto-update-relaunch.md diff --git a/docs/design/immediate-auto-update-relaunch.md b/docs/design/immediate-auto-update-relaunch.md deleted file mode 100644 index 3addfd859a1..00000000000 --- a/docs/design/immediate-auto-update-relaunch.md +++ /dev/null @@ -1,36 +0,0 @@ -# Immediate Automatic Update Relaunch - -## Problem - -Updating a global installation in place while the interactive CLI is running -removes content-hashed chunks that the old process may still import. Deferring -the install until the user exits avoids that corruption, but delays every -automatic update and reopens a session the user intentionally closed. - -## Design - -Reuse the existing update relaunch handoff: - -1. The post-render update check discovers an installable update. -2. The UI exits with the update relaunch code after normal cleanup. If a model - or tool turn, prompt or automatic-notification queue, slash command, or - unsent draft is active, it waits for a safe idle boundary. -3. The supervisor rechecks and installs the update after the child is gone. -4. The stable launcher starts the updated CLI with the original options and - resumes the exact durable session without replaying the initial prompt. For - a session with no recorded messages, the handoff independently records - whether the initial prompt was already consumed: an unconsumed prompt still - runs, while an executed slash or shell command is not replayed. - -The production wrapper supplies an explicit capability marker only after it -resolves a stable launcher, plus a private handoff file for the session ID. -Container and macOS sandboxes receive the same handoff. If no stable launcher -is available, or a conversation cannot be resumed because recording is -disabled or failed, keep the current manual guidance. Custom or manually -managed sandbox hosts remain manual. Windows standalone installs download in -the background but apply after exit because the running executable is locked. - -## Non-goals - -This does not hot-swap modules in a running process or introduce versioned -installation directories. diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 8ae4e3b2ef7..d1b6dac2ff3 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -394,11 +394,11 @@ describe('bootstrap import boundaries', () => { ); writeFileSync( path.join(oldDir, 'cli.js'), - `import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ sessionId: '123e4567-e89b-12d3-a456-426614174000' }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`, + `import { chmodSync, rmSync, writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nrmSync(${JSON.stringify(oldDir)}, { recursive: true, force: true });\nprocess.exit(44);\n`, ); writeFileSync( path.join(newDir, 'cli.js'), - "process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n", + "process.stdout.write(`${JSON.stringify({ args: process.argv.slice(2), skip: process.env.QWEN_CODE_SKIP_UPDATE_CHECK_ONCE, hasLauncherPid: /^\\d+$/.test(process.env.QWEN_CODE_LAUNCHER_PID ?? ''), launcherPath: process.env.QWEN_CODE_LAUNCHER_PATH })}\\n`);\n", ); writeFileSync( binPath, @@ -420,14 +420,8 @@ describe('bootstrap import boundaries', () => { }); expect(JSON.parse(output)).toEqual({ - args: [ - '--prompt', - 'a&b', - '--resume', - '123e4567-e89b-12d3-a456-426614174000', - ], + args: ['--prompt', 'a&b'], skip: 'true', - skipPrompt: 'true', hasLauncherPid: true, }); } finally { @@ -436,126 +430,6 @@ describe('bootstrap import boundaries', () => { } }); - it('relaunches a fresh session without forcing resume or skipping its prompt', () => { - const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-fresh-update-')); - const oldDir = path.join(tempDir, 'old'); - const newDir = path.join(tempDir, 'new'); - const binPath = path.join(tempDir, 'qwen'); - try { - mkdirSync(oldDir); - mkdirSync(newDir); - copyFileSync( - '../../scripts/cli-entry.js', - path.join(oldDir, 'entry.mjs'), - ); - copyFileSync( - '../../scripts/cli-entry.js', - path.join(newDir, 'entry.mjs'), - ); - writeFileSync( - path.join(oldDir, 'cli.js'), - `import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`, - ); - writeFileSync( - path.join(newDir, 'cli.js'), - 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n', - ); - writeFileSync( - binPath, - `#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`, - ); - chmodSync(binPath, 0o755); - - const output = execFileSync(binPath, ['--prompt-interactive', 'hello'], { - encoding: 'utf8', - env: { - ...process.env, - PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, - }, - }); - - expect(JSON.parse(output)).toEqual({ - args: ['--prompt-interactive', 'hello'], - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it('does not replay a consumed initial command in a fresh session', () => { - const tempDir = mkdtempSync( - path.join(tmpdir(), 'qwen-cli-command-update-'), - ); - const oldDir = path.join(tempDir, 'old'); - const newDir = path.join(tempDir, 'new'); - const binPath = path.join(tempDir, 'qwen'); - try { - mkdirSync(oldDir); - mkdirSync(newDir); - copyFileSync( - '../../scripts/cli-entry.js', - path.join(oldDir, 'entry.mjs'), - ); - copyFileSync( - '../../scripts/cli-entry.js', - path.join(newDir, 'entry.mjs'), - ); - writeFileSync( - path.join(oldDir, 'cli.js'), - `import { chmodSync, writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ skipInitialPrompt: true }));\nwriteFileSync(${JSON.stringify(binPath)}, ${JSON.stringify(`#!/bin/sh\nexec "${process.execPath}" "${path.join(newDir, 'entry.mjs')}" "$@"\n`)});\nchmodSync(${JSON.stringify(binPath)}, 0o755);\nprocess.exit(44);\n`, - ); - writeFileSync( - path.join(newDir, 'cli.js'), - 'process.stdout.write(JSON.stringify({ args: process.argv.slice(2), skipPrompt: process.env.QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE }));\n', - ); - writeFileSync( - binPath, - `#!/bin/sh\nexec "${process.execPath}" "${path.join(oldDir, 'entry.mjs')}" "$@"\n`, - ); - chmodSync(binPath, 0o755); - - const output = execFileSync( - binPath, - ['--prompt-interactive', '/update'], - { - encoding: 'utf8', - env: { - ...process.env, - PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, - }, - }, - ); - - expect(JSON.parse(output)).toEqual({ - args: ['--prompt-interactive', '/update'], - skipPrompt: 'true', - }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it('does not advertise update relaunch without a stable launcher', () => { - const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-no-launcher-')); - const entryPath = path.join(tempDir, 'entry.mjs'); - try { - copyFileSync('../../scripts/cli-entry.js', entryPath); - writeFileSync( - path.join(tempDir, 'cli.js'), - 'process.stdout.write(JSON.stringify({ supported: process.env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED, statePath: process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH }));\n', - ); - - const output = execFileSync(process.execPath, [entryPath], { - encoding: 'utf8', - env: { ...process.env, PATH: '' }, - }); - - expect(JSON.parse(output)).toEqual({}); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } - }); - it('does not pass the standalone launcher hint to child processes', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-launcher-env-')); const entryPath = path.join(tempDir, 'entry.mjs'); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 08c5b6129c8..9aad75b4f52 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -14,7 +14,6 @@ import { type MockInstance, } from 'vitest'; import { readFileSync } from 'node:fs'; -import os from 'node:os'; import { createNonInteractivePromptId, main, @@ -886,22 +885,9 @@ describe('gemini.tsx main function', () => { argv: string[], sessionId = '123e4567-e89b-12d3-a456-426614174000', command: 'docker' | 'podman' | 'sandbox-exec' = 'sandbox-exec', - hasUpdateSupervisor = true, ): Promise => { const originalArgv = process.argv; - const originalUpdateSupported = - process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; - const originalUpdateStatePath = - process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; process.argv = argv; - if (hasUpdateSupervisor) { - process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'] = 'true'; - process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'] = - '/tmp/qwen-update-state.json'; - } else { - delete process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; - delete process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; - } const processExitSpy = vi .spyOn(process, 'exit') .mockImplementation((code) => { @@ -953,18 +939,6 @@ describe('gemini.tsx main function', () => { } } finally { process.argv = originalArgv; - if (originalUpdateSupported === undefined) { - delete process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED']; - } else { - process.env['QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'] = - originalUpdateSupported; - } - if (originalUpdateStatePath === undefined) { - delete process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH']; - } else { - process.env['QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'] = - originalUpdateStatePath; - } processExitSpy.mockRestore(); } @@ -993,11 +967,12 @@ describe('gemini.tsx main function', () => { const { relaunchOnExitCode } = await import('./utils/relaunch.js'); const [, options] = vi.mocked(relaunchOnExitCode).mock.calls[0]!; - await expect(options?.onUpdateRelaunch?.()).resolves.toBe(44); + await expect(options?.onUpdateRelaunch?.(true)).resolves.toBe(44); expect(mockUpdateBeforeRelaunch).toHaveBeenCalledWith( expect.anything(), expect.any(String), + true, ); }); @@ -1025,53 +1000,6 @@ describe('gemini.tsx main function', () => { } }); - it('does not advertise host update relaunch without a stable launcher', async () => { - const originalCapability = process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; - - try { - await runSandboxRelaunch( - ['node', 'script.js', '--debug', '-p', 'hello'], - '', - 'docker', - false, - ); - - expect(process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']).toBe('false'); - } finally { - if (originalCapability === undefined) { - delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; - } else { - process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = originalCapability; - } - } - }); - - it('keeps Windows standalone host updates deferred', async () => { - const originalCapability = process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; - const platformSpy = vi.spyOn(os, 'platform').mockReturnValue('win32'); - mockGetInstallationInfo.mockReturnValue({ - isStandalone: true, - standaloneDir: 'C:\\qwen-code', - }); - - try { - await runSandboxRelaunch( - ['node', 'script.js', '--debug', '-p', 'hello'], - '', - 'docker', - ); - - expect(process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']).toBe('false'); - } finally { - platformSpy.mockRestore(); - if (originalCapability === undefined) { - delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; - } else { - process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = originalCapability; - } - } - }); - it('does not pass an empty session ID into the sandbox child process', async () => { const sandboxArgs = await runSandboxRelaunch( ['node', 'script.js', '--debug', '-p', 'hello'], diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index dc70bfdf695..08634692799 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -86,9 +86,7 @@ import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, - SKIP_INITIAL_PROMPT_ENV_VAR, UPDATE_COMPLETE_EXIT_CODE, - canRelaunchForUpdate, } from './utils/processUtils.js'; import { getInstallationInfo } from './utils/installationInfo.js'; @@ -297,17 +295,6 @@ export async function main() { markAcpStartup('argsParseStart'); let argv = await parseArguments(); - if (process.env[SKIP_INITIAL_PROMPT_ENV_VAR] === 'true') { - if (process.env['QWEN_CODE_NO_RELAUNCH'] || process.env['SANDBOX']) { - delete process.env[SKIP_INITIAL_PROMPT_ENV_VAR]; - } - argv = { - ...argv, - prompt: undefined, - promptInteractive: undefined, - query: undefined, - }; - } markAcpStartup('argsParseEnd'); profileCheckpoint('after_parse_arguments'); @@ -413,7 +400,7 @@ export async function main() { ? getNodeMemoryArgs(isDebugMode) : []; const updateProjectRoot = process.cwd(); - const onUpdateRelaunch = async () => { + const onUpdateRelaunch = async (relaunchOnFailure: boolean) => { await initializeI18n( resolveLanguageSetting(settings.merged.general?.language as string), ); @@ -423,6 +410,7 @@ export async function main() { const shouldRelaunch = await updateBeforeRelaunch( settings, updateProjectRoot, + relaunchOnFailure, ); return shouldRelaunch ? UPDATE_COMPLETE_EXIT_CODE : 0; }; @@ -443,11 +431,9 @@ export async function main() { const hostInstallationInfo = getInstallationInfo(updateProjectRoot, true); process.env[HOST_UPDATE_RELAUNCH_ENV_VAR] = String( Boolean( - canRelaunchForUpdate() && - (hostInstallationInfo.updateCommand || - (hostInstallationInfo.isStandalone && - hostInstallationInfo.standaloneDir && - os.platform() !== 'win32')), + hostInstallationInfo.updateCommand || + (hostInstallationInfo.isStandalone && + hostInstallationInfo.standaloneDir), ), ); } diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index c0e00fe233f..db8aa330801 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2145,6 +2145,10 @@ export default { 'Aquesta sessió utilitza la imatge de sandbox personalitzada {{image}}. Actualitzeu la imatge i reinicieu Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': "Actualitzeu Qwen Code a l'amfitrió i reinicieu l'entorn aïllat.", + 'The update will be installed after you exit this session.': + "L'actualització s'instal·larà després de sortir d'aquesta sessió.", + 'Run /update to install the update on the host.': + "Executeu /update per instal·lar l'actualització a l'amfitrió.", 'Run /update to install the update.': "Executeu /update per instal·lar l'actualització.", diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c14dcb813f1..d9c91153ed4 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2189,6 +2189,10 @@ export default { 'Diese Sitzung verwendet das benutzerdefinierte Sandbox-Image {{image}}. Aktualisieren Sie das Image und starten Sie Qwen Code neu.', 'Update Qwen Code on the host, then restart the sandbox.': 'Aktualisieren Sie Qwen Code auf dem Host und starten Sie anschließend die Sandbox neu.', + 'The update will be installed after you exit this session.': + 'Das Update wird nach dem Beenden dieser Sitzung installiert.', + 'Run /update to install the update on the host.': + 'Führen Sie /update aus, um das Update auf dem Host zu installieren.', 'Run /update to install the update.': 'Führen Sie /update aus, um das Update zu installieren.', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 4a64faca237..78fb3242f95 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2646,6 +2646,10 @@ export default { 'This session uses the custom sandbox image {{image}}. Update that image and restart Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Update Qwen Code on the host, then restart the sandbox.', + 'The update will be installed after you exit this session.': + 'The update will be installed after you exit this session.', + 'Run /update to install the update on the host.': + 'Run /update to install the update on the host.', 'Run /update to install the update.': 'Run /update to install the update.', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index f0ee0031523..0a3d33039e1 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2192,6 +2192,10 @@ export default { 'Cette session utilise l’image de bac à sable personnalisée {{image}}. Mettez à jour l’image et redémarrez Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Mettez à jour Qwen Code sur l’hôte, puis redémarrez le bac à sable.', + 'The update will be installed after you exit this session.': + 'La mise à jour sera installée après la fermeture de cette session.', + 'Run /update to install the update on the host.': + 'Exécutez /update pour installer la mise à jour sur l’hôte.', 'Run /update to install the update.': 'Exécutez /update pour installer la mise à jour.', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index a93c7497f82..34741bb2f5e 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1956,6 +1956,10 @@ export default { 'このセッションではカスタムサンドボックスイメージ {{image}} を使用しています。イメージを更新して Qwen Code を再起動してください。', 'Update Qwen Code on the host, then restart the sandbox.': 'ホスト上の Qwen Code を更新してから、サンドボックスを再起動してください。', + 'The update will be installed after you exit this session.': + 'このセッションを終了すると、更新が自動的にインストールされます。', + 'Run /update to install the update on the host.': + '/update を実行してホストに更新をインストールしてください。', 'Run /update to install the update.': '/update を実行して更新をインストールしてください。', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index ced981da418..54866ad2099 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2175,6 +2175,10 @@ export default { 'Esta sessão usa a imagem de sandbox personalizada {{image}}. Atualize a imagem e reinicie o Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Atualize o Qwen Code no host e reinicie o sandbox.', + 'The update will be installed after you exit this session.': + 'A atualização será instalada após você sair desta sessão.', + 'Run /update to install the update on the host.': + 'Execute /update para instalar a atualização no host.', 'Run /update to install the update.': 'Execute /update para instalar a atualização.', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ce11470229e..6747f4e87e5 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2163,6 +2163,10 @@ export default { 'В этом сеансе используется пользовательский образ песочницы {{image}}. Обновите образ и перезапустите Qwen Code.', 'Update Qwen Code on the host, then restart the sandbox.': 'Обновите Qwen Code на хосте, затем перезапустите песочницу.', + 'The update will be installed after you exit this session.': + 'Обновление будет установлено после выхода из этого сеанса.', + 'Run /update to install the update on the host.': + 'Запустите /update, чтобы установить обновление на хосте.', 'Run /update to install the update.': 'Запустите /update, чтобы установить обновление.', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index cd3eb112d3f..294b51bbfc2 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2237,6 +2237,10 @@ export default { '此工作階段使用自訂沙箱映像 {{image}}。請更新該映像並重新啟動 Qwen Code。', 'Update Qwen Code on the host, then restart the sandbox.': '請在主機上更新 Qwen Code,然後重新啟動沙箱。', + 'The update will be installed after you exit this session.': + '結束目前工作階段後將自動安裝更新。', + 'Run /update to install the update on the host.': + '執行 /update 在主機上安裝更新。', 'Run /update to install the update.': '執行 /update 安裝更新。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 歷史記錄缺口:此處之前的會話記錄已遺失(儲存中斷),且無法找回。', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index da8be676669..113c84e44ff 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2439,6 +2439,10 @@ export default { '此会话使用自定义沙箱镜像 {{image}}。请更新该镜像并重启 Qwen Code。', 'Update Qwen Code on the host, then restart the sandbox.': '请在宿主机上更新 Qwen Code,然后重启沙箱。', + 'The update will be installed after you exit this session.': + '退出当前会话后将自动安装更新。', + 'Run /update to install the update on the host.': + '运行 /update 在宿主机上安装更新。', 'Run /update to install the update.': '运行 /update 安装更新。', '⚠️ History gap: earlier conversation was lost before this point (storage interruption) and could not be recovered.': '⚠️ 历史记录缺口:此处之前的会话记录已丢失(存储中断),且无法找回。', diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index fd64761b8cd..5a278ff9581 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -23,7 +23,7 @@ const mockPreconnectApi = vi.hoisted(() => vi.fn()); const mockRecordStartupEvent = vi.hoisted(() => vi.fn()); const mockCheckForUpdatesDetailed = vi.hoisted(() => vi.fn()); const mockHandleAutoUpdate = vi.hoisted(() => vi.fn()); -const mockCanRelaunchForUpdate = vi.hoisted(() => vi.fn()); +const mockRequestUpdateOnExit = vi.hoisted(() => vi.fn()); const mockGetInstallationInfo = vi.hoisted(() => vi.fn()); const mockUpdateEventEmit = vi.hoisted(() => vi.fn()); const mockConnectIdeForStartup = vi.hoisted(() => vi.fn()); @@ -33,11 +33,6 @@ const mockGetIdeClientInstance = vi.hoisted(() => ); const mockInitializeTelemetry = vi.hoisted(() => vi.fn()); const mockStartBackgroundHousekeeping = vi.hoisted(() => vi.fn()); -const mockPlatform = vi.hoisted(() => vi.fn()); - -vi.mock('node:os', () => ({ - default: { platform: mockPlatform }, -})); vi.mock('@qwen-code/qwen-code-core', () => ({ createDebugLogger: () => ({ @@ -65,8 +60,7 @@ vi.mock('../utils/processUtils.js', () => ({ CUSTOM_SANDBOX_IMAGE_ENV_VAR: 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE', HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', SKIP_UPDATE_CHECK_ENV_VAR: 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE', - canRelaunchForUpdate: (...args: unknown[]) => - mockCanRelaunchForUpdate(...args), + requestUpdateOnExit: (...args: unknown[]) => mockRequestUpdateOnExit(...args), })); vi.mock('../utils/handleAutoUpdate.js', () => ({ @@ -136,8 +130,7 @@ describe('startupPrefetch', () => { updateCommand: 'npm install -g @qwen-code/qwen-code@latest', isStandalone: false, }); - mockCanRelaunchForUpdate.mockReturnValue(true); - mockPlatform.mockReturnValue('darwin'); + mockRequestUpdateOnExit.mockReturnValue(true); mockConnectIdeForStartup.mockResolvedValue(undefined); mockDisconnectIde.mockResolvedValue(undefined); mockGetIdeClientInstance.mockResolvedValue({ @@ -216,7 +209,7 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockCheckForUpdatesDetailed).toHaveBeenCalledTimes(1); - expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); expect(mockRecordStartupEvent).toHaveBeenCalledWith( 'startup_prefetch_started', { name: 'update_check' }, @@ -227,7 +220,7 @@ describe('startupPrefetch', () => { ); }); - it('relaunches immediately when an automatic update is available', async () => { + it('defers an available update until the session exits', async () => { const config = makeConfig(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -241,13 +234,15 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockCanRelaunchForUpdate).toHaveBeenCalledOnce(); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); - expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); + expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { + message: + 'Update available\nThe update will be installed after you exit this session.', + }); }); it('prompts for an explicit update when no parent supervisor is available', async () => { - mockCanRelaunchForUpdate.mockReturnValue(false); + mockRequestUpdateOnExit.mockReturnValue(false); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -264,7 +259,7 @@ describe('startupPrefetch', () => { }); }); - it('relaunches immediately for standalone updates', async () => { + it('defers standalone updates until the session exits', async () => { const config = makeConfig(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -283,11 +278,11 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); + expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); }); - it('relaunches a container immediately when the host can update', async () => { + it('keeps a container running until the user updates the host', async () => { process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH'] = 'true'; mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', @@ -300,34 +295,12 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(makeConfig(), makeSettings()); await vi.dynamicImportSettled(); - expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); expect(mockGetInstallationInfo).not.toHaveBeenCalled(); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-relaunch'); - }); - - it('stages Windows standalone updates without relaunching', async () => { - mockPlatform.mockReturnValue('win32'); - mockGetInstallationInfo.mockReturnValue({ - isStandalone: true, - standaloneDir: 'C:\\qwen-code', - }); - mockCheckForUpdatesDetailed.mockResolvedValue({ - status: 'update', - info: { - message: 'Update available', - update: { latest: '2.0.0' }, - }, + expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { + message: + 'Update available\nRun /update to install the update on the host.', }); - - startPostRenderPrefetches(makeConfig(), makeSettings()); - await vi.dynamicImportSettled(); - - expect(mockHandleAutoUpdate).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Update available' }), - expect.anything(), - '/repo', - ); - expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); }); it('keeps a container running when the host requires manual updates', async () => { @@ -343,8 +316,7 @@ describe('startupPrefetch', () => { startPostRenderPrefetches(makeConfig(), makeSettings()); await vi.dynamicImportSettled(); - expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); - expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); expect(mockHandleAutoUpdate).not.toHaveBeenCalled(); expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { message: @@ -374,8 +346,7 @@ describe('startupPrefetch', () => { await vi.dynamicImportSettled(); expect(mockCheckForUpdatesDetailed).not.toHaveBeenCalled(); - expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); - expect(mockUpdateEventEmit).not.toHaveBeenCalledWith('update-relaunch'); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); } finally { delete process.env['QWEN_CODE_CUSTOM_SANDBOX_IMAGE']; } @@ -426,7 +397,7 @@ describe('startupPrefetch', () => { message: 'Failed to check for updates. Please check your network or registry configuration.', }); - expect(mockCanRelaunchForUpdate).not.toHaveBeenCalled(); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); }); it('requires connectIde option before connecting IDE', async () => { diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index 61b378e6b6b..cf92bba8b33 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -11,7 +11,6 @@ import { type Config, } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../config/settings.js'; -import os from 'node:os'; import { preconnectApi } from '../utils/apiPreconnect.js'; import { AppEvent, appEvents } from '../utils/events.js'; import { recordStartupEvent } from '../utils/startupProfiler.js'; @@ -19,7 +18,7 @@ import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, - canRelaunchForUpdate, + requestUpdateOnExit, } from '../utils/processUtils.js'; const debugLogger = createDebugLogger('STARTUP_PREFETCH'); @@ -181,7 +180,11 @@ export function startPostRenderPrefetches( const projectRoot = config.getProjectRoot(); const hostUpdateRelaunch = process.env[HOST_UPDATE_RELAUNCH_ENV_VAR]; if (hostUpdateRelaunch === 'true') { - updateEventEmitter.emit('update-relaunch'); + updateEventEmitter.emit('update-info', { + message: `${result.info.message}\n${t( + 'Run /update to install the update on the host.', + )}`, + }); return; } if (hostUpdateRelaunch === 'false') { @@ -193,20 +196,16 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); - if ( - installationInfo.isStandalone && - installationInfo.standaloneDir && - os.platform() === 'win32' - ) { - void handleAutoUpdate(result.info, settings, projectRoot); - return; - } if ( installationInfo.updateCommand || (installationInfo.isStandalone && installationInfo.standaloneDir) ) { - if (canRelaunchForUpdate()) { - updateEventEmitter.emit('update-relaunch'); + if (requestUpdateOnExit()) { + updateEventEmitter.emit('update-info', { + message: `${result.info.message}\n${t( + 'The update will be installed after you exit this session.', + )}`, + }); } else { updateEventEmitter.emit('update-info', { message: `${result.info.message}\n${t( diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b43c23ebe60..514cbf6cbb6 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -187,7 +187,6 @@ import { migrateTomlCommands } from '../services/command-migration-tool.js'; import { sendNotification } from '../services/notificationService.js'; import { type UpdateObject } from './utils/updateCheck.js'; import { setUpdateHandler } from '../utils/handleAutoUpdate.js'; -import { prepareUpdateRelaunch } from '../utils/processUtils.js'; import { registerCleanup, runExitCleanup } from '../utils/cleanup.js'; import { useMessageQueue } from './hooks/useMessageQueue.js'; import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js'; @@ -488,12 +487,7 @@ export const AppContainer = (props: AppContainerProps) => { const [themeError, setThemeError] = useState( initializationResult.themeError, ); - const [isProcessing, setIsProcessingState] = useState(false); - const isProcessingRef = useRef(false); - const setIsProcessing = useCallback((value: boolean) => { - isProcessingRef.current = value; - setIsProcessingState(value); - }, []); + const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); const [geminiMdFileCount, setGeminiMdFileCount] = useState( @@ -1018,7 +1012,6 @@ export const AppContainer = (props: AppContainerProps) => { // Note: isIdleRef.current is assigned after streamingState becomes available // (see the assignment below useGeminiStream). const isIdleRef = useRef(true); - const hasDraftRef = useRef(false); // Live content-area height, kept in a ref so useGeminiStream (called above the // point where availableTerminalHeight is computed) can read the current value // when bounding the pending item's rendered height. terminalWidthRef pairs @@ -1030,6 +1023,16 @@ export const AppContainer = (props: AppContainerProps) => { flush: () => void; } | null>(null); + useEffect(() => { + const handler = setUpdateHandler( + historyManager.addItem, + setUpdateInfo, + isIdleRef, + ); + updateHandlerRef.current = handler; + return () => handler?.cleanup(); + }, [historyManager.addItem]); + // Derive widths for InputPrompt using shared helper const { inputWidth, suggestionsWidth } = useMemo(() => { const { inputWidth, suggestionsWidth } = @@ -1059,7 +1062,6 @@ export const AppContainer = (props: AppContainerProps) => { shellModeActive, preferredEditor, }); - hasDraftRef.current = buffer.text.length > 0; const restoredPromptStashTargetsRef = useRef(new Set()); const promptStashTargetDir = config.getTargetDir(); useEffect(() => { @@ -1879,8 +1881,6 @@ export const AppContainer = (props: AppContainerProps) => { pendingToolCalls, streamingResponseLengthRef, isReceivingContent, - isSubmittingQueryRef, - hasPendingAutomaticSubmission, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1911,8 +1911,16 @@ export const AppContainer = (props: AppContainerProps) => { // Now that streamingState is available, keep isIdleRef in sync and // flush any deferred update notifications when the model finishes responding. - const isIdle = streamingState === StreamingState.Idle; - isIdleRef.current = isIdle; + isIdleRef.current = streamingState === StreamingState.Idle; + + useEffect(() => { + if (streamingState === StreamingState.Idle) { + updateHandlerRef.current?.flush(); + // P7-trigger: a steered turn has finished — drop the `workflow active` + // indicator until the next keyword prompt re-arms it. + setWorkflowKeywordActive(false); + } + }, [streamingState]); // Auto-open the skill-review dialog when idle and there are pending skills. // Gated on the live auto-skill flag: after the dialog's turn-off option @@ -2012,47 +2020,8 @@ export const AppContainer = (props: AppContainerProps) => { restoreMessages, drainQueue, popNextSegment, - hasQueuedMessages, } = useMessageQueue(); - useEffect(() => { - const handler = setUpdateHandler( - historyManager.addItem, - setUpdateInfo, - isIdleRef, - () => - isIdleRef.current && - !isSubmittingQueryRef.current && - !hasDraftRef.current && - !isProcessingRef.current && - !hasQueuedMessages() && - !hasPendingAutomaticSubmission(), - () => - prepareUpdateRelaunch( - config, - historyRef.current.some((item) => item.type === MessageType.USER), - initialPromptSubmitted.current, - ), - ); - updateHandlerRef.current = handler; - return () => handler?.cleanup(); - }, [ - config, - hasPendingAutomaticSubmission, - hasQueuedMessages, - historyManager.addItem, - isSubmittingQueryRef, - ]); - - useEffect(() => { - if (isIdle) { - updateHandlerRef.current?.flush(); - // P7-trigger: a steered turn has finished — drop the `workflow active` - // indicator until the next keyword prompt re-arms it. - setWorkflowKeywordActive(false); - } - }, [isIdle, buffer.text, isProcessing, messageQueue.length]); - // Bridge message queue to mid-turn drain via ref. // drainQueue reads the synchronous queueRef inside the hook, so it // stays consistent with popNextSegment even before React re-renders. diff --git a/packages/cli/src/ui/commands/update-command.test.ts b/packages/cli/src/ui/commands/update-command.test.ts index c755e685db5..181da9d286f 100644 --- a/packages/cli/src/ui/commands/update-command.test.ts +++ b/packages/cli/src/ui/commands/update-command.test.ts @@ -4,14 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import os from 'node:os'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; const checkForUpdatesDetailed = vi.fn(); const relaunchForUpdate = vi.fn(); -const canRelaunchForUpdate = vi.fn(); -const prepareUpdateRelaunch = vi.fn(); const performStandaloneUpdate = vi.fn(); const getInstallationInfo = vi.fn(); const resolveUpdateCommand = vi.fn( @@ -43,8 +40,6 @@ vi.mock('../utils/updateCheck.js', () => ({ checkForUpdatesDetailed })); vi.mock('../../utils/processUtils.js', () => ({ CUSTOM_SANDBOX_IMAGE_ENV_VAR: 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE', HOST_UPDATE_RELAUNCH_ENV_VAR: 'QWEN_CODE_HOST_UPDATE_RELAUNCH', - canRelaunchForUpdate, - prepareUpdateRelaunch, relaunchForUpdate, })); vi.mock('../../utils/standalone-update.js', () => ({ @@ -69,25 +64,15 @@ function context( }, config: { getProjectRoot: () => '/repo', - getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', - getQuestion: () => '', }, }, }); } describe('updateCommand', () => { - let platformSpy: ReturnType; - beforeEach(() => { vi.clearAllMocks(); relaunchForUpdate.mockReset(); - canRelaunchForUpdate.mockReturnValue(true); - prepareUpdateRelaunch.mockResolvedValue({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - skipInitialPrompt: true, - }); - platformSpy = vi.spyOn(os, 'platform').mockReturnValue('darwin'); delete process.env['QWEN_CODE_CUSTOM_SANDBOX_IMAGE']; delete process.env['QWEN_CODE_HOST_UPDATE_RELAUNCH']; checkForUpdatesDetailed.mockResolvedValue({ @@ -103,20 +88,13 @@ describe('updateCommand', () => { }); }); - afterEach(() => { - platformSpy.mockRestore(); - }); - it('hands an interactive update off to the parent process', async () => { const commandContext = context('interactive'); const result = await updateCommand.action!(commandContext, ''); expect(result).toBeUndefined(); - expect(relaunchForUpdate).toHaveBeenCalledWith( - '123e4567-e89b-12d3-a456-426614174000', - true, - ); + expect(relaunchForUpdate).toHaveBeenCalledTimes(1); expect( commandContext.services.settings.merged.general?.enableAutoUpdate, ).toBeUndefined(); @@ -186,25 +164,6 @@ describe('updateCommand', () => { expect(relaunchForUpdate).toHaveBeenCalledTimes(1); }); - it('stages a Windows standalone update without closing the session', async () => { - platformSpy.mockReturnValue('win32'); - getInstallationInfo.mockReturnValue({ - isStandalone: true, - standaloneDir: 'C:\\qwen-code', - }); - performStandaloneUpdate.mockResolvedValue('deferred'); - - const result = await updateCommand.action!(context('interactive'), ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'Update available: 1.2.3\nDownloading update...\nUpdate downloaded. It will be applied after you exit this session.', - }); - expect(relaunchForUpdate).not.toHaveBeenCalled(); - }); - it('does not mutate enableAutoUpdate when relaunching throws', async () => { const commandContext = context('interactive'); relaunchForUpdate.mockImplementation(() => { @@ -235,34 +194,6 @@ describe('updateCommand', () => { expect(relaunchForUpdate).not.toHaveBeenCalled(); }); - it('falls back to manual guidance without a stable launcher', async () => { - canRelaunchForUpdate.mockReturnValue(false); - - const result = await updateCommand.action!(context('interactive'), ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', - }); - expect(relaunchForUpdate).not.toHaveBeenCalled(); - }); - - it('keeps a non-resumable interactive session open', async () => { - prepareUpdateRelaunch.mockResolvedValue(null); - - const result = await updateCommand.action!(context('interactive'), ''); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: - 'Update available: 1.2.3\nRun the following to update:\n npm install -g @qwen-code/qwen-code@1.2.3', - }); - expect(relaunchForUpdate).not.toHaveBeenCalled(); - }); - it('returns the manual update command in ACP mode', async () => { const result = await updateCommand.action!(context('acp'), ''); diff --git a/packages/cli/src/ui/commands/update-command.ts b/packages/cli/src/ui/commands/update-command.ts index ee0ebd5397b..9cc9c0d0737 100644 --- a/packages/cli/src/ui/commands/update-command.ts +++ b/packages/cli/src/ui/commands/update-command.ts @@ -7,7 +7,6 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import os from 'node:os'; export const updateCommand: SlashCommand = { name: 'update', @@ -22,8 +21,6 @@ export const updateCommand: SlashCommand = { { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, - canRelaunchForUpdate, - prepareUpdateRelaunch, relaunchForUpdate, }, { performStandaloneUpdate }, @@ -37,8 +34,7 @@ export const updateCommand: SlashCommand = { const { formatUpdateInstructions, getInstallationInfo } = installationInfo; const settings = context.services.settings; - const config = context.services.config; - const projectRoot = config?.getProjectRoot(); + const projectRoot = context.services.config?.getProjectRoot(); const updateCheck = await checkForUpdatesDetailed(); @@ -88,38 +84,8 @@ export const updateCommand: SlashCommand = { content: lines.join('\n'), }; }; - const updateStandalone = async () => { - try { - const result = await performStandaloneUpdate( - installInfo.standaloneDir!, - info.update.latest, - ); - const message = - result === 'done' - ? t( - 'Update successful! The new version will be used on your next run.', - ) - : t( - 'Update downloaded. It will be applied after you exit this session.', - ); - return { - type: 'message' as const, - messageType: 'info' as const, - content: `${info.message}\n${t('Downloading update...')}\n${message}`, - }; - } catch (err) { - const message = t('Update failed: {{error}}', { - error: err instanceof Error ? err.message : String(err), - }); - return { - type: 'message' as const, - messageType: 'error' as const, - content: `${info.message}\n${message}`, - }; - } - }; - if (context.executionMode === 'interactive' && projectRoot && config) { + if (context.executionMode === 'interactive' && projectRoot) { const customSandboxImage = process.env[CUSTOM_SANDBOX_IMAGE_ENV_VAR]; if (customSandboxImage) { return { @@ -135,13 +101,7 @@ export const updateCommand: SlashCommand = { const isAutoUpdateEnabled = settings.merged.general?.enableAutoUpdate !== false; if (hostUpdateRelaunch === 'true' && isAutoUpdateEnabled) { - const prepared = await prepareUpdateRelaunch( - config, - context.ui.history.some((item) => item.type === 'user'), - Boolean(config.getQuestion()), - ); - if (!prepared) return manualInstructions(); - await relaunchForUpdate(prepared.sessionId, prepared.skipInitialPrompt); + await relaunchForUpdate(); return; } if (hostUpdateRelaunch !== undefined) { @@ -156,28 +116,42 @@ export const updateCommand: SlashCommand = { const canAutoUpdate = installInfo.updateCommand || (installInfo.isStandalone && installInfo.standaloneDir); - if ( - installInfo.isStandalone && - installInfo.standaloneDir && - os.platform() === 'win32' - ) { - return updateStandalone(); - } - if (isAutoUpdateEnabled && canAutoUpdate && canRelaunchForUpdate()) { - const prepared = await prepareUpdateRelaunch( - config, - context.ui.history.some((item) => item.type === 'user'), - Boolean(config.getQuestion()), - ); - if (!prepared) return manualInstructions(); - await relaunchForUpdate(prepared.sessionId, prepared.skipInitialPrompt); + if (isAutoUpdateEnabled && canAutoUpdate) { + await relaunchForUpdate(); return; } return manualInstructions(); } if (installInfo.isStandalone && installInfo.standaloneDir) { - return updateStandalone(); + try { + const result = await performStandaloneUpdate( + installInfo.standaloneDir, + info.update.latest, + ); + const message = + result === 'done' + ? t( + 'Update successful! The new version will be used on your next run.', + ) + : t( + 'Update downloaded. It will be applied after you exit this session.', + ); + return { + type: 'message' as const, + messageType: 'info' as const, + content: `${info.message}\n${t('Downloading update...')}\n${message}`, + }; + } catch (err) { + const message = t('Update failed: {{error}}', { + error: err instanceof Error ? err.message : String(err), + }); + return { + type: 'message' as const, + messageType: 'error' as const, + content: `${info.message}\n${message}`, + }; + } } // Non-interactive / ACP mode: report the available update and manual command. diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 7f67d6fb71e..61beb142c10 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -396,7 +396,7 @@ describe('useGeminiStream', () => { }; it('queues background shell terminal notifications for the model loop', async () => { - const { mockSendMessageStream, result } = renderTestHook(); + const { mockSendMessageStream } = renderTestHook(); const displayText = 'Background shell "npm test" completed.'; const modelText = '\nshell\ncompleted\n'; @@ -412,7 +412,6 @@ describe('useGeminiStream', () => { act(() => { callback(displayText, modelText); - expect(result.current.hasPendingAutomaticSubmission()).toBe(true); }); await waitFor(() => { @@ -432,7 +431,6 @@ describe('useGeminiStream', () => { }), ); }); - expect(result.current.hasPendingAutomaticSubmission()).toBe(false); }); describe('vision bridge gate', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index d51e4a73b85..2be5bb40dbf 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3893,13 +3893,6 @@ export const useGeminiStream = ( } }, [streamingState, submitQuery, teammateTrigger, addItem]); - const hasPendingAutomaticSubmission = useCallback( - () => - notificationQueueRef.current.length > 0 || - teammateQueueRef.current.length > 0, - [], - ); - return { streamingState, submitQuery, @@ -3914,7 +3907,5 @@ export const useGeminiStream = ( loopDetectionConfirmationRequest, streamingResponseLengthRef, isReceivingContent, - isSubmittingQueryRef, - hasPendingAutomaticSubmission, }; }; diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index 52a56f468a7..05ff3f0ba5b 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -23,7 +23,6 @@ describe('useMessageQueue', () => { expect(result.current.messageQueue).toEqual([]); expect(result.current.getQueuedMessagesText()).toBe(''); - expect(result.current.hasQueuedMessages()).toBe(false); }); it('should add messages to queue', () => { @@ -38,7 +37,6 @@ describe('useMessageQueue', () => { 'Test message 1', 'Test message 2', ]); - expect(result.current.hasQueuedMessages()).toBe(true); }); it('should filter out empty messages', () => { @@ -71,7 +69,6 @@ describe('useMessageQueue', () => { }); expect(result.current.messageQueue).toEqual([]); - expect(result.current.hasQueuedMessages()).toBe(false); }); it('should return queued messages as text with double newlines', () => { diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index e4ee3df9ac1..448d49e7f95 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -24,7 +24,6 @@ export interface UseMessageQueueReturn { drainQueue: (includeDeferred?: boolean) => string[]; /** Pop the first item from the queue. */ popNextSegment: () => string | null; - hasQueuedMessages: () => boolean; } interface QueuedMessage { @@ -98,7 +97,6 @@ export function useMessageQueue(): UseMessageQueueReturn { setQueuedMessages(rest); return head.text; }, []); - const hasQueuedMessages = useCallback(() => queueRef.current.length > 0, []); return { messageQueue: queuedMessages.map(({ text }) => text), @@ -109,6 +107,5 @@ export function useMessageQueue(): UseMessageQueueReturn { restoreMessages, drainQueue, popNextSegment, - hasQueuedMessages, }; } diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 3a9990a44b7..94a2184bea7 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -16,8 +16,6 @@ import { performStandaloneUpdate } from './standalone-update.js'; import { MessageType } from '../ui/types.js'; import os from 'node:os'; -const mockRelaunchForUpdate = vi.hoisted(() => vi.fn()); - vi.mock('./installationInfo.js', async () => { const actual = await vi.importActual('./installationInfo.js'); return { @@ -37,10 +35,6 @@ vi.mock('./updateEventEmitter.js', async () => { }; }); -vi.mock('./processUtils.js', () => ({ - relaunchForUpdate: (...args: unknown[]) => mockRelaunchForUpdate(...args), -})); - interface MockChildProcess extends EventEmitter { stdin: EventEmitter & { write: Mock; @@ -427,7 +421,6 @@ describe('setUpdateHandler', () => { beforeEach(() => { addItem = vi.fn(); setUpdateInfo = vi.fn(); - mockRelaunchForUpdate.mockReset().mockResolvedValue(undefined); updateEventEmitter.removeAllListeners(); }); @@ -490,151 +483,6 @@ describe('setUpdateHandler', () => { cleanup(); }); - it('relaunches immediately when idle', async () => { - const isIdleRef = { current: true }; - const { cleanup } = setUpdateHandler( - addItem, - setUpdateInfo, - isIdleRef, - () => true, - () => ({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - skipInitialPrompt: true, - }), - ); - - updateEventEmitter.emit('update-relaunch'); - - await vi.waitFor(() => - expect(mockRelaunchForUpdate).toHaveBeenCalledWith( - '123e4567-e89b-12d3-a456-426614174000', - true, - ), - ); - cleanup(); - }); - - it('waits for an unsent draft to be cleared before relaunching', async () => { - const isIdleRef = { current: true }; - const canRelaunchRef = { current: false }; - const { cleanup, flush } = setUpdateHandler( - addItem, - setUpdateInfo, - isIdleRef, - () => canRelaunchRef.current, - () => ({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - skipInitialPrompt: true, - }), - ); - - updateEventEmitter.emit('update-relaunch'); - expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); - - canRelaunchRef.current = true; - flush(); - - await vi.waitFor(() => - expect(mockRelaunchForUpdate).toHaveBeenCalledWith( - '123e4567-e89b-12d3-a456-426614174000', - true, - ), - ); - cleanup(); - }); - - it('keeps a non-resumable session open', async () => { - const isIdleRef = { current: true }; - const { cleanup } = setUpdateHandler( - addItem, - setUpdateInfo, - isIdleRef, - () => true, - () => null, - ); - - updateEventEmitter.emit('update-relaunch'); - - await vi.waitFor(() => - expect(addItem).toHaveBeenCalledWith( - { - type: MessageType.INFO, - text: 'Run /update to install the update.', - }, - expect.any(Number), - ), - ); - expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); - cleanup(); - }); - - it('rechecks safety after preparing the relaunch', async () => { - let resolvePreparation!: (value: { sessionId: string }) => void; - const preparation = new Promise<{ sessionId: string }>((resolve) => { - resolvePreparation = resolve; - }); - let safe = true; - const { cleanup, flush } = setUpdateHandler( - addItem, - setUpdateInfo, - { current: true }, - () => safe, - () => preparation, - ); - - updateEventEmitter.emit('update-relaunch'); - safe = false; - resolvePreparation({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - }); - await Promise.resolve(); - await Promise.resolve(); - expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); - - safe = true; - flush(); - await vi.waitFor(() => - expect(mockRelaunchForUpdate).toHaveBeenCalledOnce(), - ); - cleanup(); - }); - - it('defers relaunch until the active turn becomes idle', async () => { - const isIdleRef = { current: false }; - const { cleanup, flush } = setUpdateHandler( - addItem, - setUpdateInfo, - isIdleRef, - ); - - updateEventEmitter.emit('update-relaunch'); - expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); - - isIdleRef.current = true; - flush(); - - await vi.waitFor(() => - expect(mockRelaunchForUpdate).toHaveBeenCalledOnce(), - ); - cleanup(); - }); - - it('cancels a deferred relaunch during cleanup', () => { - const isIdleRef = { current: false }; - const { cleanup, flush } = setUpdateHandler( - addItem, - setUpdateInfo, - isIdleRef, - ); - - updateEventEmitter.emit('update-relaunch'); - cleanup(); - isIdleRef.current = true; - flush(); - - expect(mockRelaunchForUpdate).not.toHaveBeenCalled(); - }); - it('should defer addItem when not idle (update-success)', () => { const isIdleRef = { current: false }; const { cleanup } = setUpdateHandler(addItem, setUpdateInfo, isIdleRef); diff --git a/packages/cli/src/utils/handleAutoUpdate.ts b/packages/cli/src/utils/handleAutoUpdate.ts index 8c7560a3fd9..c302b48acc9 100644 --- a/packages/cli/src/utils/handleAutoUpdate.ts +++ b/packages/cli/src/utils/handleAutoUpdate.ts @@ -21,7 +21,6 @@ import { t } from '../i18n/index.js'; import type { spawn } from 'node:child_process'; import os from 'node:os'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { relaunchForUpdate } from './processUtils.js'; const debugLogger = createDebugLogger('AUTO_UPDATE'); @@ -149,15 +148,8 @@ export function setUpdateHandler( addItem: (item: HistoryItemWithoutId, timestamp: number) => void, setUpdateInfo: (info: UpdateObject | null) => void, isIdleRef: { current: boolean } = { current: true }, - canRelaunch: () => boolean = () => isIdleRef.current, - prepareRelaunch: () => - | Promise<{ sessionId?: string; skipInitialPrompt?: boolean } | null> - | { sessionId?: string; skipInitialPrompt?: boolean } - | null = () => ({}), ) { let successfullyInstalled = false; - let relaunchPending = false; - let disposed = false; const pendingNotifications: HistoryItemWithoutId[] = []; const addItemOrDefer = (item: HistoryItemWithoutId) => { @@ -206,52 +198,17 @@ export function setUpdateHandler( }); }; - const runUpdateRelaunch = () => { - relaunchPending = false; - void Promise.resolve(prepareRelaunch()) - .then((prepared) => { - if (disposed) return; - if (!canRelaunch()) { - relaunchPending = true; - return; - } - if (!prepared) { - handleUpdateInfo({ - message: t('Run /update to install the update.'), - }); - return; - } - return relaunchForUpdate( - prepared.sessionId, - prepared.skipInitialPrompt, - ); - }) - .catch(() => handleUpdateFailed()); - }; - - const handleUpdateRelaunch = () => { - if (canRelaunch()) { - runUpdateRelaunch(); - } else { - relaunchPending = true; - } - }; - updateEventEmitter.on('update-received', handleUpdateReceived); updateEventEmitter.on('update-failed', handleUpdateFailed); updateEventEmitter.on('update-success', handleUpdateSuccess); updateEventEmitter.on('update-info', handleUpdateInfo); - updateEventEmitter.on('update-relaunch', handleUpdateRelaunch); const cleanup = () => { - disposed = true; updateEventEmitter.off('update-received', handleUpdateReceived); updateEventEmitter.off('update-failed', handleUpdateFailed); updateEventEmitter.off('update-success', handleUpdateSuccess); updateEventEmitter.off('update-info', handleUpdateInfo); - updateEventEmitter.off('update-relaunch', handleUpdateRelaunch); pendingNotifications.length = 0; - relaunchPending = false; }; const flush = () => { @@ -259,9 +216,6 @@ export function setUpdateHandler( const item = pendingNotifications.shift()!; addItem(item, Date.now()); } - if (relaunchPending && canRelaunch()) { - runUpdateRelaunch(); - } }; return { cleanup, flush }; diff --git a/packages/cli/src/utils/processUtils.test.ts b/packages/cli/src/utils/processUtils.test.ts index 47bb239c29d..aff233662b3 100644 --- a/packages/cli/src/utils/processUtils.test.ts +++ b/packages/cli/src/utils/processUtils.test.ts @@ -5,21 +5,15 @@ */ import { afterEach, beforeEach, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { RELAUNCH_EXIT_CODE, - UPDATE_RELAUNCH_STATE_PATH_ENV_VAR, + UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, - UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, - canRelaunchForUpdate, - prepareUpdateRelaunch, relaunchApp, relaunchForUpdate, + requestUpdateOnExit, } from './processUtils.js'; import * as cleanup from './cleanup.js'; -import type { Config } from '@qwen-code/qwen-code-core'; describe('processUtils', () => { const processExit = vi @@ -27,27 +21,13 @@ describe('processUtils', () => { .mockReturnValue(undefined as never); const runExitCleanup = vi.spyOn(cleanup, 'runExitCleanup'); const originalSend = process.send; - const originalSupported = process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; - const originalStatePath = process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; beforeEach(() => { vi.clearAllMocks(); - delete process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; - delete process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; }); afterEach(() => { process.send = originalSend; - if (originalSupported === undefined) { - delete process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR]; - } else { - process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = originalSupported; - } - if (originalStatePath === undefined) { - delete process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; - } else { - process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = originalStatePath; - } }); it('should run cleanup and exit with the relaunch code', async () => { @@ -62,71 +42,17 @@ describe('processUtils', () => { expect(processExit).toHaveBeenCalledWith(UPDATE_RELAUNCH_EXIT_CODE); }); - it('detects an update relaunch supervisor', () => { - process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = 'true'; - process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = '/tmp/relaunch.json'; + it('requests a deferred update from the parent process', () => { + const send = vi.fn(); + process.send = send; - expect(canRelaunchForUpdate()).toBe(true); + expect(requestUpdateOnExit()).toBe(true); + expect(send).toHaveBeenCalledWith({ type: UPDATE_ON_EXIT_MESSAGE }); }); - it('does not infer a supervisor from IPC alone', () => { - process.send = vi.fn(); + it('does not request a deferred update without a parent process', () => { + process.send = undefined; - expect(canRelaunchForUpdate()).toBe(false); - }); - - it('writes the resumed session before exiting for an update', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-update-state-')); - const statePath = path.join(dir, 'state.json'); - process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] = 'true'; - process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR] = statePath; - - try { - await relaunchForUpdate('123e4567-e89b-12d3-a456-426614174000'); - - expect(JSON.parse(fs.readFileSync(statePath, 'utf8'))).toEqual({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - skipInitialPrompt: true, - }); - expect(processExit).toHaveBeenCalledWith(UPDATE_RELAUNCH_EXIT_CODE); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it('only resumes sessions that have a durable transcript', async () => { - const getSessionLocation = vi - .fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce('active'); - const config = { - getChatRecordingService: () => ({ flush: vi.fn() }), - getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', - getSessionService: () => ({ getSessionLocation }), - } as unknown as Config; - - await expect(prepareUpdateRelaunch(config, false)).resolves.toEqual({ - skipInitialPrompt: false, - }); - await expect(prepareUpdateRelaunch(config, true)).resolves.toEqual({ - sessionId: '123e4567-e89b-12d3-a456-426614174000', - skipInitialPrompt: true, - }); - getSessionLocation.mockResolvedValueOnce(undefined); - await expect(prepareUpdateRelaunch(config, true)).resolves.toBeNull(); - }); - - it('preserves whether a fresh initial prompt was already consumed', async () => { - const config = { - getChatRecordingService: () => ({ flush: vi.fn() }), - getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', - getSessionService: () => ({ - getSessionLocation: vi.fn().mockResolvedValue(undefined), - }), - } as unknown as Config; - - await expect(prepareUpdateRelaunch(config, false, true)).resolves.toEqual({ - skipInitialPrompt: true, - }); + expect(requestUpdateOnExit()).toBe(false); }); }); diff --git a/packages/cli/src/utils/processUtils.ts b/packages/cli/src/utils/processUtils.ts index 125ab522c73..a838155dd6a 100644 --- a/packages/cli/src/utils/processUtils.ts +++ b/packages/cli/src/utils/processUtils.ts @@ -5,8 +5,6 @@ */ import { runExitCleanup } from './cleanup.js'; -import fs from 'node:fs'; -import type { Config } from '@qwen-code/qwen-code-core'; /** * Exit code used to signal that the CLI should be relaunched. @@ -19,17 +17,11 @@ export const UPDATE_COMPLETE_EXIT_CODE = 44; export const SKIP_UPDATE_CHECK_ENV_VAR = 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE'; -export const SKIP_INITIAL_PROMPT_ENV_VAR = 'QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE'; - export const CUSTOM_SANDBOX_IMAGE_ENV_VAR = 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE'; export const HOST_UPDATE_RELAUNCH_ENV_VAR = 'QWEN_CODE_HOST_UPDATE_RELAUNCH'; -export const UPDATE_RELAUNCH_SUPPORTED_ENV_VAR = - 'QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED'; - -export const UPDATE_RELAUNCH_STATE_PATH_ENV_VAR = - 'QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH'; +export const UPDATE_ON_EXIT_MESSAGE = 'qwen-code:update-on-exit'; /** * Exits the process with a special code to signal that the parent process should relaunch it. @@ -39,45 +31,17 @@ export async function relaunchApp(): Promise { process.exit(RELAUNCH_EXIT_CODE); } -export async function relaunchForUpdate( - sessionId?: string, - skipInitialPrompt = Boolean(sessionId), -): Promise { - const statePath = process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; - if (statePath && (sessionId || skipInitialPrompt)) { - fs.writeFileSync( - statePath, - JSON.stringify({ sessionId, skipInitialPrompt }), - { - encoding: 'utf8', - mode: 0o600, - }, - ); - } +export async function relaunchForUpdate(): Promise { await runExitCleanup(); process.exit(UPDATE_RELAUNCH_EXIT_CODE); } -export function canRelaunchForUpdate(): boolean { - return ( - process.env[UPDATE_RELAUNCH_SUPPORTED_ENV_VAR] === 'true' && - Boolean(process.env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]) - ); -} - -export async function prepareUpdateRelaunch( - config: Config, - hasUserMessages: boolean, - initialPromptConsumed = false, -): Promise<{ sessionId?: string; skipInitialPrompt: boolean } | null> { +export function requestUpdateOnExit(): boolean { + if (!process.send) return false; try { - await config.getChatRecordingService()?.flush(); - const sessionId = config.getSessionId(); - if (await config.getSessionService().getSessionLocation(sessionId)) { - return { sessionId, skipInitialPrompt: true }; - } + process.send({ type: UPDATE_ON_EXIT_MESSAGE }); + return true; } catch { - if (hasUserMessages) return null; + return false; } - return hasUserMessages ? null : { skipInitialPrompt: initialPromptConsumed }; } diff --git a/packages/cli/src/utils/relaunch.test.ts b/packages/cli/src/utils/relaunch.test.ts index 7ffa68f2809..bfeda18c7b8 100644 --- a/packages/cli/src/utils/relaunch.test.ts +++ b/packages/cli/src/utils/relaunch.test.ts @@ -16,6 +16,7 @@ import { import { EventEmitter } from 'node:events'; import { RELAUNCH_EXIT_CODE, + UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, } from './processUtils.js'; import type { ChildProcess } from 'node:child_process'; @@ -92,7 +93,7 @@ describe('relaunchOnExitCode', () => { relaunchOnExitCode(runner, { onUpdateRelaunch }), ).rejects.toThrow('PROCESS_EXIT_CALLED'); - expect(onUpdateRelaunch).toHaveBeenCalledOnce(); + expect(onUpdateRelaunch).toHaveBeenCalledWith(true); expect(runner).toHaveBeenCalledTimes(1); expect(processExitSpy).toHaveBeenCalledWith(0); }); @@ -336,7 +337,7 @@ describe('relaunchAppInChildProcess', () => { expect(afterSpawn).toHaveBeenCalledTimes(1); }); - it('does not update or relaunch after a clean child exit', async () => { + it('installs a requested automatic update only after a clean child exit', async () => { process.argv = ['/usr/bin/node', '/app/cli.js']; const onUpdateRelaunch = vi.fn().mockResolvedValue(44); @@ -347,9 +348,37 @@ describe('relaunchAppInChildProcess', () => { onUpdateRelaunch, }); + mockChild.emit('message', { type: UPDATE_ON_EXIT_MESSAGE }); + expect(onUpdateRelaunch).not.toHaveBeenCalled(); + mockChild.emit('close', 0); await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + expect(onUpdateRelaunch).toHaveBeenCalledWith(false); + expect(processExitSpy).toHaveBeenCalledWith(44); + }); + + it('does not carry an update request across relaunches', async () => { + process.argv = ['/usr/bin/node', '/app/cli.js']; + + const onUpdateRelaunch = vi.fn().mockResolvedValue(44); + const firstChild = createMockChildProcess(0, false); + const secondChild = createMockChildProcess(0, false); + mockedSpawn + .mockReturnValueOnce(firstChild) + .mockReturnValueOnce(secondChild); + + const promise = relaunchAppInChildProcess([], [], { + onUpdateRelaunch, + }); + + firstChild.emit('message', { type: UPDATE_ON_EXIT_MESSAGE }); + firstChild.emit('close', RELAUNCH_EXIT_CODE); + await vi.waitFor(() => expect(mockedSpawn).toHaveBeenCalledTimes(2)); + + secondChild.emit('close', 0); + await expect(promise).rejects.toThrow('PROCESS_EXIT_CALLED'); + expect(onUpdateRelaunch).not.toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); }); diff --git a/packages/cli/src/utils/relaunch.ts b/packages/cli/src/utils/relaunch.ts index cd2d5c9e766..e4572d1360a 100644 --- a/packages/cli/src/utils/relaunch.ts +++ b/packages/cli/src/utils/relaunch.ts @@ -7,13 +7,14 @@ import { spawn } from 'node:child_process'; import { RELAUNCH_EXIT_CODE, + UPDATE_ON_EXIT_MESSAGE, UPDATE_RELAUNCH_EXIT_CODE, } from './processUtils.js'; import { writeStderrLine } from './stdioHelpers.js'; interface RelaunchOptions { afterSpawn?: () => void; - onUpdateRelaunch?: () => Promise | number; + onUpdateRelaunch?: (relaunchOnFailure: boolean) => Promise | number; } export async function relaunchOnExitCode( @@ -25,7 +26,7 @@ export async function relaunchOnExitCode( const exitCode = await runner(); if (exitCode === UPDATE_RELAUNCH_EXIT_CODE && options?.onUpdateRelaunch) { - const updatedExitCode = await options.onUpdateRelaunch(); + const updatedExitCode = await options.onUpdateRelaunch(true); process.exit(updatedExitCode); } @@ -51,6 +52,8 @@ export async function relaunchAppInChildProcess( } const runner = () => { + let updateOnExitRequested = false; + // process.argv is [node, script, ...args] // We want to construct [ ...nodeArgs, script, ...scriptArgs] const script = process.argv[1]; @@ -75,10 +78,21 @@ export async function relaunchAppInChildProcess( process.stdin.pause(); const child = spawn(process.execPath, nodeArgs, { - stdio: 'inherit', + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], env: newEnv, }); + child.on('message', (message) => { + if ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === UPDATE_ON_EXIT_MESSAGE + ) { + updateOnExitRequested = true; + } + }); + // Allow the parent to clean up process.env after spawn copies it // but before the next relaunch iteration. try { @@ -93,7 +107,20 @@ export async function relaunchAppInChildProcess( child.on('close', (code) => { // Resume stdin before the parent process exits. process.stdin.resume(); - resolve(code ?? 1); + const exitCode = code ?? 1; + if ( + exitCode === 0 && + updateOnExitRequested && + options?.onUpdateRelaunch + ) { + updateOnExitRequested = false; + void Promise.resolve(options.onUpdateRelaunch(false)).then( + (updatedExitCode) => resolve(updatedExitCode), + reject, + ); + return; + } + resolve(exitCode); }); }); }; diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index f19de795a6f..71bb6ff7443 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -135,25 +135,16 @@ describe('getSandboxPassthroughEnvArgs', () => { expect( getSandboxPassthroughEnvArgs({ QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', - QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE: 'true', QWEN_CODE_CUSTOM_SANDBOX_IMAGE: 'example.com/qwen:1.0.0', QWEN_CODE_HOST_UPDATE_RELAUNCH: 'false', - QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true', - QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: '/tmp/relaunch/state.json', }), ).toEqual([ - '--env', - 'QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE=true', '--env', 'QWEN_CODE_SKIP_UPDATE_CHECK_ONCE=true', '--env', 'QWEN_CODE_CUSTOM_SANDBOX_IMAGE=example.com/qwen:1.0.0', '--env', 'QWEN_CODE_HOST_UPDATE_RELAUNCH=false', - '--env', - 'QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED=true', - '--env', - 'QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH=/tmp/relaunch/state.json', ]); }); }); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index bdb8afba6ff..1e8b4c5c25c 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -29,10 +29,7 @@ import { parseSandboxMountSpec } from './sandboxMounts.js'; import { CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, - SKIP_INITIAL_PROMPT_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, - UPDATE_RELAUNCH_STATE_PATH_ENV_VAR, - UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, } from './processUtils.js'; const execAsync = promisify(exec); @@ -72,25 +69,15 @@ const BUILTIN_SEATBELT_PROFILES = [ export function getSandboxPassthroughEnvArgs( env: NodeJS.ProcessEnv = process.env, ): string[] { - const args = [ + return [ 'QWEN_DEBUG_LOG_FILE', 'QWEN_CODE_LEGACY_MCP_BLOCKING', - SKIP_INITIAL_PROMPT_ENV_VAR, SKIP_UPDATE_CHECK_ENV_VAR, CUSTOM_SANDBOX_IMAGE_ENV_VAR, HOST_UPDATE_RELAUNCH_ENV_VAR, - UPDATE_RELAUNCH_SUPPORTED_ENV_VAR, ].flatMap((envVar) => env[envVar] === undefined ? [] : ['--env', `${envVar}=${env[envVar]}`], ); - const statePath = env[UPDATE_RELAUNCH_STATE_PATH_ENV_VAR]; - if (statePath) { - args.push( - '--env', - `${UPDATE_RELAUNCH_STATE_PATH_ENV_VAR}=${getContainerPath(statePath)}`, - ); - } - return args; } export function resolveSeatbeltProfileFile( diff --git a/packages/cli/src/utils/update-relaunch.test.ts b/packages/cli/src/utils/update-relaunch.test.ts index d4ee11f6ac9..557bdb9823c 100644 --- a/packages/cli/src/utils/update-relaunch.test.ts +++ b/packages/cli/src/utils/update-relaunch.test.ts @@ -46,7 +46,7 @@ describe('updateBeforeRelaunch', () => { const updateProcess = new EventEmitter(); handleAutoUpdate.mockReturnValue(updateProcess); - const update = updateBeforeRelaunch(settings, '/repo'); + const update = updateBeforeRelaunch(settings, '/repo', false); await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); expect(writeStderrLine).toHaveBeenCalledWith('Update available'); expect(writeStderrLine).not.toHaveBeenCalledWith( @@ -61,24 +61,32 @@ describe('updateBeforeRelaunch', () => { ); }); - it('reports update failure and relaunches the old version', async () => { - const updateProcess = new EventEmitter(); - handleAutoUpdate.mockReturnValue(updateProcess); - - const update = updateBeforeRelaunch(settings, '/repo'); - await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); - updateProcess.emit('close', 1); - await expect(update).resolves.toBe(true); - - expect(writeStderrLine).toHaveBeenCalledWith( - 'Automatic update failed. Please try updating manually.', - ); - }); + it.each([ + ['explicit update', true, true], + ['background update-on-exit', false, false], + ] as const)( + 'reports %s failure and returns %s', + async (_source, relaunchOnFailure, expected) => { + const updateProcess = new EventEmitter(); + handleAutoUpdate.mockReturnValue(updateProcess); + + const update = updateBeforeRelaunch(settings, '/repo', relaunchOnFailure); + await vi.waitFor(() => expect(handleAutoUpdate).toHaveBeenCalledTimes(1)); + updateProcess.emit('close', 1); + await expect(update).resolves.toBe(expected); + + expect(writeStderrLine).toHaveBeenCalledWith( + 'Automatic update failed. Please try updating manually.', + ); + }, + ); it('relaunches the old version when the update check fails', async () => { checkForUpdatesDetailed.mockResolvedValue({ status: 'error' }); - await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(true); + await expect(updateBeforeRelaunch(settings, '/repo', true)).resolves.toBe( + true, + ); expect(writeStderrLine).toHaveBeenCalledWith( 'Failed to check for updates. Please check your network or registry configuration.', ); @@ -96,7 +104,7 @@ describe('updateBeforeRelaunch', () => { }), ); - const update = updateBeforeRelaunch(settings, '/repo'); + const update = updateBeforeRelaunch(settings, '/repo', false); await vi.waitFor(() => expect(performStandaloneUpdate).toHaveBeenCalledWith('/qwen', '2.0.0'), ); @@ -113,7 +121,9 @@ describe('updateBeforeRelaunch', () => { }); performStandaloneUpdate.mockResolvedValue('deferred'); - await expect(updateBeforeRelaunch(settings, '/repo')).resolves.toBe(false); + await expect(updateBeforeRelaunch(settings, '/repo', false)).resolves.toBe( + false, + ); expect(writeStderrLine).toHaveBeenCalledWith( 'Update downloaded. It will be applied after you exit this session.', ); diff --git a/packages/cli/src/utils/update-relaunch.ts b/packages/cli/src/utils/update-relaunch.ts index f8a9430878d..d618888b8dd 100644 --- a/packages/cli/src/utils/update-relaunch.ts +++ b/packages/cli/src/utils/update-relaunch.ts @@ -15,6 +15,7 @@ const UPDATE_FAILED_MESSAGE = export async function updateBeforeRelaunch( settings: LoadedSettings, projectRoot: string, + relaunchOnFailure: boolean, ): Promise { let translate = (message: string) => message; try { @@ -56,7 +57,7 @@ export async function updateBeforeRelaunch( installationInfo.updateMessage ?? t('Manual update required. Please reinstall Qwen Code.'), ); - return true; + return relaunchOnFailure; } const updateProcess = handleAutoUpdate( result.info, @@ -76,12 +77,12 @@ export async function updateBeforeRelaunch( : UPDATE_FAILED_MESSAGE, ), ); - return true; + return success || relaunchOnFailure; } else if (result.status === 'error') { writeStderrLine(t(UPDATE_CHECK_FAILED_MESSAGE)); } } catch { writeStderrLine(translate(UPDATE_FAILED_MESSAGE)); } - return true; + return relaunchOnFailure; } diff --git a/scripts/cli-entry.js b/scripts/cli-entry.js index 9f89d7de29f..f4178b6b60c 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -40,40 +40,6 @@ function hasFlag(flag, alias) { return false; } -function withResumeSession(args, sessionId) { - const result = []; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--') { - return [...result, '--resume', sessionId, ...args.slice(i)]; - } - if (arg === '--continue' || arg === '-c' || arg.startsWith('--continue=')) { - continue; - } - const sessionFlag = - arg === '--resume' || - arg === '-r' || - arg === '--session-id' || - arg === '--sandbox-session-id'; - if (sessionFlag) { - if (args[i + 1] && args[i + 1] !== '--' && !args[i + 1].startsWith('-')) { - i++; - } - continue; - } - if ( - arg.startsWith('--resume=') || - arg.startsWith('--session-id=') || - arg.startsWith('--sandbox-session-id=') - ) { - continue; - } - result.push(arg); - } - result.push('--resume', sessionId); - return result; -} - function isInProcessFastPath() { const first = cliArgs[0]; if (first === 'serve' || first === 'mcp') { @@ -94,9 +60,7 @@ if (isTopLevelVersion && process.env['CLI_VERSION']) { process.exit(0); } -const { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } = - await import('node:fs'); -const { tmpdir } = await import('node:os'); +const { existsSync, realpathSync } = await import('node:fs'); const { fileURLToPath, pathToFileURL } = await import('node:url'); const { delimiter, dirname, join, parse, resolve, sep } = await import( 'node:path' @@ -216,41 +180,11 @@ if (isInProcessFastPath()) { ...process.env, QWEN_CODE_LAUNCHER_PID: String(process.pid), }; - const relaunchStateDir = launcher - ? mkdtempSync(join(tmpdir(), 'qwen-code-relaunch-')) - : undefined; - const relaunchStatePath = relaunchStateDir - ? join(relaunchStateDir, 'state.json') - : undefined; - if (relaunchStatePath) { - env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED = 'true'; - env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH = relaunchStatePath; - } const result = spawnSync( process.execPath, ['--expose-gc', cliPath, ...cliArgs], { stdio: 'inherit', env }, ); - let relaunchSessionId; - let skipInitialPrompt = false; - if ( - result.status === UPDATE_COMPLETE_EXIT_CODE && - relaunchStatePath && - existsSync(relaunchStatePath) - ) { - try { - const state = JSON.parse(readFileSync(relaunchStatePath, 'utf8')); - if (typeof state.sessionId === 'string') { - relaunchSessionId = state.sessionId; - } - skipInitialPrompt = state.skipInitialPrompt === true; - } catch { - // Relaunch with the original arguments if the handoff state is invalid. - } - } - if (relaunchStateDir) { - rmSync(relaunchStateDir, { recursive: true, force: true }); - } if (result.signal) { process.kill(process.pid, result.signal); @@ -265,14 +199,7 @@ if (isInProcessFastPath()) { } const relaunchEnv = { ...process.env, - QWEN_CODE_RELAUNCH_ARGS: JSON.stringify( - relaunchSessionId - ? withResumeSession(cliArgs, relaunchSessionId) - : cliArgs, - ), - ...(relaunchSessionId || skipInitialPrompt - ? { QWEN_CODE_SKIP_INITIAL_PROMPT_ONCE: 'true' } - : {}), + QWEN_CODE_RELAUNCH_ARGS: JSON.stringify(cliArgs), QWEN_CODE_SKIP_UPDATE_CHECK_ONCE: 'true', }; const relaunchResult = From e7f0e81023a59446e19a8b658352658c01eb13f4 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sun, 19 Jul 2026 20:41:41 +0800 Subject: [PATCH 5/7] test(cli): cover update registry edge cases --- packages/cli/src/ui/utils/updateCheck.test.ts | 24 +++++++++++++++++++ .../cli/src/utils/handleAutoUpdate.test.ts | 19 +++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index bc255e75f48..a883fcbb813 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -364,6 +364,30 @@ describe('checkForUpdates', () => { ).resolves.toBe(false); }); + it('does not treat local npm installs as global npm installs', async () => { + const run = vi.fn().mockResolvedValue({ + stdout: '/global/node_modules\n', + stderr: '', + }); + const canonicalize = vi.fn(async (candidate: string) => + candidate === '/global/node_modules' + ? '/global/node_modules' + : '/repo/node_modules/@qwen-code/qwen-code/cli.js', + ); + + await expect( + isGlobalNpmInstallation( + '/repo/node_modules/@qwen-code/qwen-code/cli.js', + run as unknown as NonNullable< + Parameters[1] + >, + canonicalize as unknown as NonNullable< + Parameters[2] + >, + ), + ).resolves.toBe(false); + }); + it('does not fall back when the global npm query fails', async () => { const run = vi.fn().mockRejectedValue(new Error('npm view failed')); diff --git a/packages/cli/src/utils/handleAutoUpdate.test.ts b/packages/cli/src/utils/handleAutoUpdate.test.ts index 94a2184bea7..f4c7fa06044 100644 --- a/packages/cli/src/utils/handleAutoUpdate.test.ts +++ b/packages/cli/src/utils/handleAutoUpdate.test.ts @@ -269,6 +269,25 @@ describe('handleAutoUpdate', () => { ); }); + it('runs non-npm package-manager updates through the shell', () => { + vi.spyOn(os, 'platform').mockReturnValue('linux'); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'pnpm add -g @qwen-code/qwen-code@latest', + isGlobal: true, + packageManager: PackageManager.PNPM, + }); + + handleAutoUpdate(mockUpdateInfo, mockSettings, '/root', mockSpawn); + + expect(mockSpawn).toHaveBeenCalledWith( + 'bash', + ['-c', 'pnpm add -g @qwen-code/qwen-code@2.0.0'], + { + stdio: ['pipe', 'ignore', 'pipe'], + }, + ); + }); + it('should emit "update-success" when the update process succeeds', async () => { await new Promise((resolve) => { mockGetInstallationInfo.mockReturnValue({ From ee56a7791f69ba8b1d94a0d19631fae6bf799215 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Sun, 19 Jul 2026 20:45:45 +0800 Subject: [PATCH 6/7] fix(cli): resolve CLI path before global-npm match and harden update paths - isGlobalNpmInstallation now realpath-resolves argv[1] before matching the npm package path (consistent with getInstallationInfo), so a bin-symlink launch no longer silently skips the global-npm update path. - getNpmCliPath falls back to /lib/node_modules/npm/bin/npm-cli.js on POSIX instead of throwing when npm is not adjacent to node. - fetchGlobalNpmUpdateInfo treats an empty `npm view dist-tags.` response as no-update instead of failing the whole check, so a missing nightly tag on a private mirror no longer poisons the latest check. --- packages/cli/src/ui/utils/updateCheck.test.ts | 39 +++++++++++++++++++ packages/cli/src/ui/utils/updateCheck.ts | 34 +++++++++++++--- packages/cli/src/utils/installationInfo.ts | 21 +++++++++- 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index a883fcbb813..f7c37f24808 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -279,6 +279,30 @@ describe('checkForUpdates', () => { expect(run).not.toHaveBeenCalled(); }); + it('resolves a bin symlink before matching the npm package path', async () => { + const run = vi.fn().mockResolvedValue({ + stdout: '/usr/local/lib/node_modules\n', + stderr: '', + }); + const canonicalize = vi.fn(async (candidate: string) => + candidate === '/usr/local/bin/qwen' + ? '/usr/local/lib/node_modules/@qwen-code/qwen-code/dist/cli.js' + : '/usr/local/lib/node_modules', + ); + + await expect( + isGlobalNpmInstallation( + '/usr/local/bin/qwen', + run as unknown as NonNullable< + Parameters[1] + >, + canonicalize as unknown as NonNullable< + Parameters[2] + >, + ), + ).resolves.toBe(true); + }); + it('runs the Windows npm CLI through Node without a shell', async () => { const run = vi.fn().mockResolvedValue({ stdout: '"1.1.0"', stderr: '' }); const resolveNpmCliPath = vi @@ -403,6 +427,21 @@ describe('checkForUpdates', () => { ).rejects.toThrow('npm view failed'); }); + it('treats an empty dist-tag response as no update', async () => { + const run = vi.fn().mockResolvedValue({ stdout: '\n', stderr: '' }); + + await expect( + fetchGlobalNpmUpdateInfo( + '@qwen-code/qwen-code', + '1.0.0', + 'nightly', + run as unknown as NonNullable< + Parameters[3] + >, + ), + ).resolves.toMatchObject({ current: '1.0.0', latest: '1.0.0' }); + }); + it('should pass a non-optional package version to update-notifier', async () => { getPackageJson.mockResolvedValue({ name: 'test-package', diff --git a/packages/cli/src/ui/utils/updateCheck.ts b/packages/cli/src/ui/utils/updateCheck.ts index 7aae97d5fac..af12a056375 100644 --- a/packages/cli/src/ui/utils/updateCheck.ts +++ b/packages/cli/src/ui/utils/updateCheck.ts @@ -97,11 +97,21 @@ export async function isGlobalNpmInstallation( run: typeof execFileAsync = execFileAsync, canonicalize: typeof realpath = realpath, ): Promise { - if (!cliPath || !looksLikeNpmPackagePath(cliPath)) return false; - const [resolvedCliPath, unresolvedGlobalRoot] = await Promise.all([ - canonicalize(cliPath), - runGlobalNpm(['root', '--global'], run), - ]); + if (!cliPath) return false; + // Canonicalize before matching. The CLI can be launched through its global + // bin symlink (e.g. `.../bin/qwen`), whose path carries no `node_modules` + // segment, and Node does not resolve `process.argv[1]` symlinks. Matching the + // raw path would silently skip the global-npm path here, unlike + // getInstallationInfo which realpath-resolves first. + let resolvedCliPath: string; + try { + resolvedCliPath = await canonicalize(cliPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } + if (!looksLikeNpmPackagePath(resolvedCliPath)) return false; + const unresolvedGlobalRoot = await runGlobalNpm(['root', '--global'], run); let globalRoot: string; try { globalRoot = await canonicalize(unresolvedGlobalRoot); @@ -128,6 +138,20 @@ export async function fetchGlobalNpmUpdateInfo( ['view', packageName, `dist-tags.${distTag}`, '--json', '--global'], run, ); + if (output === '') { + // `npm view dist-tags. --json` exits 0 with empty stdout when the + // configured registry/mirror publishes no version under this dist-tag (e.g. + // a private mirror that doesn't carry `nightly`). Treat that as "no newer + // version for this tag" instead of throwing — otherwise the empty result + // reaches JSON.parse and, via the Promise.all in checkForUpdatesDetailed, + // fails the whole check and discards the other tag's result. + return { + latest: currentVersion, + current: currentVersion, + type: 'latest', + name: packageName, + }; + } const latest: unknown = JSON.parse(output); if (typeof latest !== 'string') { throw new Error(`Invalid npm ${distTag} version response`); diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts index 6578dae0e3f..f1c424b2a35 100644 --- a/packages/cli/src/utils/installationInfo.ts +++ b/packages/cli/src/utils/installationInfo.ts @@ -35,7 +35,26 @@ export function getNpmCliPath( 'npm-cli.js', ); } - return fs.realpathSync(path.join(path.dirname(nodePath), 'npm')); + // Prefer the npm symlink that sits next to the node binary and resolve it to + // the real npm-cli.js. On split layouts where npm is not adjacent to node, + // fall back to the conventional `/lib/node_modules/npm` location + // instead of throwing synchronously — getNpmCliPath is called from a + // non-async site (handleAutoUpdate), and a returned best-effort path lets the + // downstream spawn surface any failure through its 'error' handler. + const adjacentNpm = path.join(path.dirname(nodePath), 'npm'); + try { + return fs.realpathSync(adjacentNpm); + } catch { + return path.join( + path.dirname(nodePath), + '..', + 'lib', + 'node_modules', + 'npm', + 'bin', + 'npm-cli.js', + ); + } } const debugLogger = createDebugLogger('INSTALLATION_INFO'); From 95643c2d9eeb5ce6c67ffb856febad17ab0cb3be Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sun, 19 Jul 2026 22:05:11 +0800 Subject: [PATCH 7/7] test(cli): stub global-npm probe in updateCheck timeout tests The #6857 timeout tests called checkForUpdatesDetailed() with the default detectGlobalNpm (real isGlobalNpmInstallation), which runs a real async realpath() before the timeout setTimeout is armed. Under fake timers this races with vi.advanceTimersByTimeAsync: on a slow/loaded runner the advance completes before the timer is scheduled, so the timeout never fires and the test hangs until vitest's 15s limit. Seen on ubuntu-latest CI (2 of 4 hung). Inject a synchronous `async () => false` stub so the timer is armed deterministically and the tests isolate the timeout logic from global-npm detection. --- packages/cli/src/ui/utils/updateCheck.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/utils/updateCheck.test.ts b/packages/cli/src/ui/utils/updateCheck.test.ts index f7c37f24808..1d9a98f2852 100644 --- a/packages/cli/src/ui/utils/updateCheck.test.ts +++ b/packages/cli/src/ui/utils/updateCheck.test.ts @@ -515,7 +515,11 @@ describe('checkForUpdates', () => { fetchInfo: vi.fn().mockReturnValue(new Promise(() => {})), }); - const resultPromise = checkForUpdatesDetailed(); + // Stub the global-npm probe: the real isGlobalNpmInstallation runs a + // real realpath() I/O before the timeout is armed, which races with the + // fake-timer advance below and makes this test hang non-deterministically + // on slow/loaded runners. + const resultPromise = checkForUpdatesDetailed(async () => false); await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); const result = await resultPromise; @@ -543,7 +547,7 @@ describe('checkForUpdates', () => { .mockResolvedValue({ current: '1.0.0', latest: '1.1.0' }), }); - const result = await checkForUpdatesDetailed(); + const result = await checkForUpdatesDetailed(async () => false); expect(result.status).toBe('update'); if (result.status === 'update') { @@ -572,7 +576,7 @@ describe('checkForUpdates', () => { }), })); - const resultPromise = checkForUpdatesDetailed(); + const resultPromise = checkForUpdatesDetailed(async () => false); await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); const result = await resultPromise; @@ -597,7 +601,7 @@ describe('checkForUpdates', () => { fetchInfo: () => new Promise(() => {}), })); - const resultPromise = checkForUpdatesDetailed(); + const resultPromise = checkForUpdatesDetailed(async () => false); await vi.advanceTimersByTimeAsync(FETCH_TIMEOUT_MS + 1); const result = await resultPromise;