diff --git a/docs/design/immediate-auto-update-relaunch.md b/docs/design/immediate-auto-update-relaunch.md new file mode 100644 index 00000000000..992fcd7d9f4 --- /dev/null +++ b/docs/design/immediate-auto-update-relaunch.md @@ -0,0 +1,37 @@ +# 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 and sessions started with `--worktree` 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..ebc55d8f8e7 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -15,7 +15,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { FatalError } from '@qwen-code/qwen-code-core'; @@ -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, @@ -411,22 +411,247 @@ describe('bootstrap import boundaries', () => { ); chmodSync(path.join(wrongDir, 'qwen'), 0o755); - const output = execFileSync(binPath, ['--prompt', 'a&b'], { + const output = execFileSync( + binPath, + [ + '--prompt', + 'a&b', + '--fork-session', + 'false', + '-r=old-session', + '-c', + 'false', + ], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${wrongDir}${path.delimiter}${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + }, + }, + ); + + expect(JSON.parse(output)).toEqual({ + args: [ + '--prompt', + 'a&b', + '--resume=123e4567-e89b-12d3-a456-426614174000', + ], + skip: 'true', + skipPrompt: 'true', + hasLauncherPid: true, + }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + rmSync(wrongDir, { recursive: true, force: true }); + } + }); + + it('rejects an invalid resume session from the update handoff', () => { + 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(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, JSON.stringify({ sessionId: '--no-sandbox' }));\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: `${wrongDir}${path.delimiter}${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + PATH: `${tempDir}${path.delimiter}${process.env['PATH'] ?? ''}`, }, }); expect(JSON.parse(output)).toEqual({ - args: ['--prompt', 'a&b'], - skip: 'true', - hasLauncherPid: true, + args: ['--prompt-interactive', 'hello'], }); } finally { rmSync(tempDir, { recursive: true, force: true }); - rmSync(wrongDir, { 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 advertise update relaunch for startup worktrees', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-worktree-')); + const entryPath = path.join(tempDir, 'entry.mjs'); + const launcherPath = path.join(tempDir, 'qwen'); + 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', + ); + writeFileSync(launcherPath, '#!/bin/sh\n'); + chmodSync(launcherPath, 0o755); + + for (const worktreeArg of ['--worktree', '--worktree=feature']) { + const output = execFileSync( + process.execPath, + [entryPath, worktreeArg], + { + encoding: 'utf8', + env: { + ...process.env, + QWEN_CODE_LAUNCHER_PATH: launcherPath, + QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true', + QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: '/tmp/forged-state.json', + }, + }, + ); + + expect(JSON.parse(output)).toEqual({}); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('preserves the host update handoff across a sandbox wrapper', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-sandbox-')); + const entryPath = path.join(tempDir, 'entry.mjs'); + const statePath = path.join(tempDir, 'state.json'); + try { + copyFileSync('../../scripts/cli-entry.js', entryPath); + writeFileSync( + path.join(tempDir, 'cli.js'), + `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH, '{"skipInitialPrompt":true}');\nprocess.exit(43);\n`, + ); + + const result = spawnSync(process.execPath, [entryPath], { + env: { + ...process.env, + SANDBOX: 'container', + QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED: 'true', + QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH: statePath, + }, + }); + + expect(result.status).toBe(43); + expect(readFileSync(statePath, 'utf8')).toBe( + '{"skipInitialPrompt":true}', + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('starts normally when the relaunch state directory is unavailable', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-no-tmp-')); + const entryPath = path.join(tempDir, 'entry.mjs'); + const launcherPath = path.join(tempDir, 'qwen'); + try { + copyFileSync('../../scripts/cli-entry.js', entryPath); + writeFileSync(path.join(tempDir, 'cli.js'), 'process.exit(0);\n'); + writeFileSync(launcherPath, '#!/bin/sh\n'); + chmodSync(launcherPath, 0o755); + + const result = spawnSync(process.execPath, [entryPath], { + env: { + ...process.env, + TMPDIR: '/dev/null', + QWEN_CODE_LAUNCHER_PATH: launcherPath, + }, + }); + + expect(result.status).toBe(0); + } finally { + rmSync(tempDir, { recursive: true, force: true }); } }); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9aad75b4f52..5f4581ca09a 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,13 +993,9 @@ 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, - ); + expect(mockUpdateBeforeRelaunch).toHaveBeenCalledTimes(1); }); it('passes host update capability into a container sandbox', async () => { @@ -1000,6 +1022,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 adeb7da5e9f..a2120d4313c 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2141,10 +2141,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 5b6506adbf1..ced92d73c2c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -186,7 +186,9 @@ import { useCommandMigration } from './hooks/useCommandMigration.js'; import { migrateTomlCommands } from '../services/command-migration-tool.js'; import { sendNotification } from '../services/notificationService.js'; import { type UpdateObject } from './utils/updateCheck.js'; +import { hasBlockingBackgroundWork } from './utils/backgroundWorkUtils.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,7 +490,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( @@ -1013,6 +1020,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 @@ -1024,16 +1032,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 } = @@ -1063,6 +1061,7 @@ export const AppContainer = (props: AppContainerProps) => { shellModeActive, preferredEditor, }); + hasDraftRef.current = buffer.text.length > 0; const restoredPromptStashTargetsRef = useRef(new Set()); const promptStashTargetDir = config.getTargetDir(); useEffect(() => { @@ -1882,6 +1881,8 @@ export const AppContainer = (props: AppContainerProps) => { pendingToolCalls, streamingResponseLengthRef, isReceivingContent, + isSubmittingQueryRef, + hasPendingAutomaticSubmission, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1912,16 +1913,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 @@ -2021,8 +2014,53 @@ 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() && + !hasBlockingBackgroundWork(config) && + !config.getTeamManager() && + !config.getArenaManager(), + () => + prepareUpdateRelaunch( + config, + historyRef.current.some((item) => item.type === MessageType.USER), + initialPromptSubmitted.current, + ), + () => setIsProcessing(true), + () => setIsProcessing(false), + ); + updateHandlerRef.current = handler; + return () => handler?.cleanup(); + }, [ + config, + hasPendingAutomaticSubmission, + hasQueuedMessages, + historyManager.addItem, + isSubmittingQueryRef, + setIsProcessing, + ]); + + 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, bgTaskEntries]); + // 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 71487c6335b..742eb77c69f 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 3c055d34703..3a5ada04661 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -3944,6 +3944,13 @@ export const useGeminiStream = ( } }, [streamingState, submitQuery, teammateTrigger, addItem]); + const hasPendingAutomaticSubmission = useCallback( + () => + notificationQueueRef.current.length > 0 || + teammateQueueRef.current.length > 0, + [], + ); + return { streamingState, submitQuery, @@ -3958,5 +3965,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 4997257c4de..0fbb67bce3b 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 9b40dfa0592..e7961cdc3b2 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 { @@ -100,6 +101,7 @@ export function useMessageQueue(): UseMessageQueueReturn { setQueuedMessages(rest); return head.text; }, []); + const hasQueuedMessages = useCallback(() => queueRef.current.length > 0, []); return { messageQueue: queuedMessages.map(({ text }) => text), @@ -110,5 +112,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 f4c7fa06044..f9f4714dce9 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; @@ -440,6 +446,7 @@ describe('setUpdateHandler', () => { beforeEach(() => { addItem = vi.fn(); setUpdateInfo = vi.fn(); + mockRelaunchForUpdate.mockReset().mockResolvedValue(undefined); updateEventEmitter.removeAllListeners(); }); @@ -502,6 +509,178 @@ describe('setUpdateHandler', () => { cleanup(); }); + it('relaunches immediately when idle', async () => { + const isIdleRef = { current: true }; + const onRelaunchStart = vi.fn(); + const onRelaunchError = vi.fn(); + const { cleanup } = setUpdateHandler( + addItem, + setUpdateInfo, + isIdleRef, + () => true, + () => ({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + skipInitialPrompt: true, + }), + onRelaunchStart, + onRelaunchError, + ); + + updateEventEmitter.emit('update-relaunch'); + + await vi.waitFor(() => + expect(mockRelaunchForUpdate).toHaveBeenCalledWith( + '123e4567-e89b-12d3-a456-426614174000', + true, + ), + ); + expect(onRelaunchStart).toHaveBeenCalledOnce(); + expect(onRelaunchError).not.toHaveBeenCalled(); + cleanup(); + }); + + it('re-enables input when relaunch fails', async () => { + const onRelaunchStart = vi.fn(); + const onRelaunchError = vi.fn(); + mockRelaunchForUpdate.mockRejectedValueOnce(new Error('relaunch failed')); + const { cleanup } = setUpdateHandler( + addItem, + setUpdateInfo, + { current: true }, + () => true, + () => ({}), + onRelaunchStart, + onRelaunchError, + ); + + updateEventEmitter.emit('update-relaunch'); + + await vi.waitFor(() => expect(onRelaunchError).toHaveBeenCalledOnce()); + expect(onRelaunchStart).toHaveBeenCalledOnce(); + 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..8c732be55e9 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,17 @@ 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 = () => ({}), + onRelaunchStart: () => void = () => {}, + onRelaunchError: () => void = () => {}, ) { let successfullyInstalled = false; + let relaunchPending = false; + let disposed = false; const pendingNotifications: HistoryItemWithoutId[] = []; const addItemOrDefer = (item: HistoryItemWithoutId) => { @@ -198,17 +208,56 @@ 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; + } + onRelaunchStart(); + return relaunchForUpdate( + prepared.sessionId, + prepared.skipInitialPrompt, + ); + }) + .catch(() => { + onRelaunchError(); + 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 +265,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..0c3589ad770 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,83 @@ 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('does not resume a stale transcript when recording is disabled', async () => { + const getSessionLocation = vi.fn().mockResolvedValue('active'); + const config = { + getChatRecordingService: () => undefined, + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + getSessionService: () => ({ getSessionLocation }), + } as unknown as Config; + + await expect(prepareUpdateRelaunch(config, true)).resolves.toBeNull(); + expect(getSessionLocation).not.toHaveBeenCalled(); + }); + + 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..fb1a741ff50 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,51 @@ 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> { + const recorder = config.getChatRecordingService(); + if (!recorder) { + return hasUserMessages + ? null + : { skipInitialPrompt: initialPromptConsumed }; + } try { - process.send({ type: UPDATE_ON_EXIT_MESSAGE }); - return true; + await recorder.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..ef6dbe57210 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).toHaveBeenCalledWith(); 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..a062286dd84 100755 --- a/scripts/cli-entry.js +++ b/scripts/cli-entry.js @@ -20,6 +20,8 @@ */ const relaunchArgs = process.env['QWEN_CODE_RELAUNCH_ARGS']; +const validSessionId = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i; let cliArgs = process.argv.slice(2); try { cliArgs = relaunchArgs ? JSON.parse(relaunchArgs) : cliArgs; @@ -33,13 +35,65 @@ function hasFlag(flag, alias) { if (arg === '--') { return false; } - if (arg === flag || arg === alias) { + if ( + arg === flag || + arg.startsWith(`${flag}=`) || + (alias && (arg === alias || arg.startsWith(`${alias}=`))) + ) { return true; } } 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)]; + } + const booleanSessionFlag = + arg === '--continue' || arg === '-c' || arg === '--fork-session'; + if ( + booleanSessionFlag || + arg.startsWith('--continue=') || + arg.startsWith('-c=') || + arg.startsWith('--fork-session=') + ) { + if ( + booleanSessionFlag && + (args[i + 1] === 'true' || args[i + 1] === 'false') + ) { + i++; + } + 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('-r=') || + 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 +114,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 +236,54 @@ if (isInProcessFastPath()) { ...process.env, QWEN_CODE_LAUNCHER_PID: String(process.pid), }; + const inSandbox = Boolean(process.env['SANDBOX']); + if (!inSandbox) { + delete env.QWEN_CODE_UPDATE_RELAUNCH_SUPPORTED; + delete env.QWEN_CODE_UPDATE_RELAUNCH_STATE_PATH; + } + let relaunchStateDir; + if (!inSandbox && launcher && !hasFlag('--worktree')) { + try { + relaunchStateDir = mkdtempSync(join(tmpdir(), 'qwen-code-relaunch-')); + } catch { + // Automatic updates fall back to manual instructions. + } + } + 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' && + validSessionId.test(state.sessionId) + ) { + 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 +298,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 =