From 4d5015389de5b7fbb3ecb217e319f06580bb7d94 Mon Sep 17 00:00:00 2001 From: Aleks-0 Date: Thu, 9 Jul 2026 04:04:47 +0300 Subject: [PATCH 1/5] chore(core): remove stale refreshStartupContextReminder mocks from tool-search tests (#6423) Two inline mock clients still stubbed refreshStartupContextReminder after it was removed from tool-search.ts. makeConfigWithRegistry() was cleaned up in the original PR but these two were missed. Co-authored-by: Aleks-0 --- packages/core/src/tools/tool-search.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/tools/tool-search.test.ts b/packages/core/src/tools/tool-search.test.ts index 7f0366994e..065d748076 100644 --- a/packages/core/src/tools/tool-search.test.ts +++ b/packages/core/src/tools/tool-search.test.ts @@ -601,7 +601,6 @@ describe('ToolSearchTool', () => { const setToolsSpy = vi.fn().mockResolvedValue(undefined); vi.spyOn(config, 'getGeminiClient').mockReturnValue({ setTools: setToolsSpy, - refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), } as never); const tool = new ToolSearchTool(config); @@ -646,7 +645,6 @@ describe('ToolSearchTool', () => { const setToolsSpy = vi.fn().mockResolvedValue(undefined); vi.spyOn(config, 'getGeminiClient').mockReturnValue({ setTools: setToolsSpy, - refreshStartupContextReminder: vi.fn().mockResolvedValue(undefined), } as never); const tool = new ToolSearchTool(config); From 25423b15269f6ee4660431163799c7b313dc53a9 Mon Sep 17 00:00:00 2001 From: callmeYe <512217680@qq.com> Date: Thu, 9 Jul 2026 09:04:56 +0800 Subject: [PATCH 2/5] fix(cli): align memory dialog with managed memory (#6434) * fix(cli): align memory dialog with managed memory * test(cli): stabilize memory dialog path rendering * fix(cli): make memory target switch exhaustive * fix(cli): tighten memory dialog target handling * fix(cli): handle headless managed memory dialog * test(cli): cover desktop managed memory dialog branches * fix(cli): open memory folders asynchronously * test(cli): assert managed memory folder setup * fix(cli): simplify memory folder opener * fix(cli): clarify memory folder opener behavior --- .../cli/src/ui/commands/memoryCommand.test.ts | 4 + packages/cli/src/ui/commands/memoryCommand.ts | 1 - .../src/ui/components/MemoryDialog.test.tsx | 312 +++++++++++++++++- .../cli/src/ui/components/MemoryDialog.tsx | 136 +++++--- packages/core/src/tools/read-file.ts | 3 +- .../components/StreamingStatus.test.tsx | 9 +- 6 files changed, 418 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index 5836cf8569..97986f69ad 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -21,4 +21,8 @@ describe('memoryCommand', () => { dialog: 'memory', }); }); + + it('does not advertise unsupported subcommands', () => { + expect(memoryCommand.argumentHint).toBeUndefined(); + }); }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 8370884ac6..65c27a6018 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -13,7 +13,6 @@ export const memoryCommand: SlashCommand = { get description() { return t('Open the memory manager.'); }, - argumentHint: 'show|add|refresh', kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: async () => ({ diff --git a/packages/cli/src/ui/components/MemoryDialog.test.tsx b/packages/cli/src/ui/components/MemoryDialog.test.tsx index 8c6d93f84a..27b00074cd 100644 --- a/packages/cli/src/ui/components/MemoryDialog.test.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.test.tsx @@ -7,6 +7,16 @@ import { act } from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from 'ink-testing-library'; +import { spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { + AUTO_MEMORY_INDEX_FILENAME, + clearAutoMemoryRootCache, + getAutoMemoryRoot, +} from '@qwen-code/qwen-code-core'; import { MemoryDialog } from './MemoryDialog.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -29,16 +39,88 @@ vi.mock('../hooks/useKeypress.js', () => ({ useKeypress: vi.fn(), })); +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + const fsMock = { + access: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(() => Promise.reject(new Error('not found'))), + writeFile: vi.fn(), + }; + return { + ...actual, + ...fsMock, + default: { ...actual, ...fsMock }, + }; +}); + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const { EventEmitter } = await import('node:events'); + const spawnMock = vi.fn(() => { + const child = new EventEmitter() as EventEmitter & { + unref: ReturnType; + }; + child.unref = vi.fn(); + queueMicrotask(() => child.emit('spawn')); + return child; + }); + return { + ...actual, + spawn: spawnMock, + default: { ...actual, spawn: spawnMock }, + }; +}); + const mockedUseConfig = vi.mocked(useConfig); const mockedUseSettings = vi.mocked(useSettings); const mockedUseLaunchEditor = vi.mocked(useLaunchEditor); const mockedUseKeypress = vi.mocked(useKeypress); +const mockedSpawn = vi.mocked(spawn); +const mockedFs = vi.mocked(fs); +const originalPlatform = process.platform; + +type MockSpawnChild = EventEmitter & { unref: ReturnType }; +const folderOpenCommandByPlatform: Partial> = { + darwin: 'open', + win32: 'explorer', +}; + +function createMockSpawnChild( + event: 'spawn' | 'error' = 'spawn', + error?: Error, +): MockSpawnChild { + const child = new EventEmitter() as MockSpawnChild; + child.unref = vi.fn(); + queueMicrotask(() => { + child.emit(event, error); + }); + return child; +} + +function stubPlatform(platform: NodeJS.Platform): void { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +function expectedFolderOpenCommand(platform = process.platform): string { + return folderOpenCommandByPlatform[platform] ?? 'xdg-open'; +} describe('MemoryDialog', () => { let setAutoSkillEnabled: ReturnType; beforeEach(() => { vi.clearAllMocks(); + vi.stubEnv('DISPLAY', ':99'); + vi.stubEnv('QWEN_HOME', path.join(os.homedir(), '.qwen')); + vi.stubEnv( + 'QWEN_CODE_MEMORY_BASE_DIR', + path.join(os.homedir(), '.qwen-memory-test'), + ); + clearAutoMemoryRootCache(); setAutoSkillEnabled = vi.fn(); mockedUseConfig.mockReturnValue({ @@ -51,6 +133,7 @@ describe('MemoryDialog', () => { getManagedAutoMemoryEnabled: vi.fn(() => false), getManagedAutoDreamEnabled: vi.fn(() => false), getAutoSkillEnabled: vi.fn(() => false), + isManagedMemoryAvailable: vi.fn(() => true), setAutoSkillEnabled, } as never); @@ -69,21 +152,185 @@ describe('MemoryDialog', () => { }); afterEach(() => { + stubPlatform(originalPlatform); + clearAutoMemoryRootCache(); + vi.unstubAllEnvs(); vi.restoreAllMocks(); }); - it('moves selection with down arrow key events', () => { + it('renders managed memory folders without advertising QWEN.md', () => { const { lastFrame } = render(); expect(lastFrame()).toContain('› 1. User memory'); + expect(lastFrame()).toContain('2. Project memory'); + expect(lastFrame()).toContain('/memories'); + expect(lastFrame()).toContain('/memory'); + expect(lastFrame()).not.toContain('QWEN.md'); + expect(lastFrame()).not.toContain('Open auto-memory folder'); + }); + + it('opens managed memory folders instead of launching an editor', async () => { + const onClose = vi.fn(); + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + + render(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockedSpawn).toHaveBeenCalledWith( + expectedFolderOpenCommand(), + [path.join(os.homedir(), '.qwen-memory-test', 'memories')], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(mockedFs.mkdir).toHaveBeenCalledWith( + path.join(os.homedir(), '.qwen-memory-test', 'memories'), + { recursive: true }, + ); + expect( + (mockedSpawn.mock.results[0]?.value as MockSpawnChild).unref, + ).toHaveBeenCalled(); + expect(launchEditor).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it.each(['darwin', 'win32'] as const)( + 'opens managed memory folders on %s without display variables', + async (platform) => { + stubPlatform(platform); + vi.stubEnv('DISPLAY', ''); + vi.stubEnv('WAYLAND_DISPLAY', ''); + vi.stubEnv('MIR_SOCKET', ''); + const onClose = vi.fn(); + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + + render(); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockedSpawn).toHaveBeenCalledWith( + expectedFolderOpenCommand(platform), + [path.join(os.homedir(), '.qwen-memory-test', 'memories')], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(launchEditor).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }, + ); + + it('opens the project managed memory folder after moving selection down', async () => { + const onClose = vi.fn(); + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + const { lastFrame } = render(); + + expect(lastFrame()).toContain('› 1. User memory'); act(() => { - keypressHandler({ name: 'down' } as never); + mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'down' } as never); }); expect(lastFrame()).toContain('› 2. Project memory'); + + await act(async () => { + mockedUseKeypress.mock.calls.at(-1)![0]({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockedSpawn).toHaveBeenCalledWith( + expectedFolderOpenCommand(), + [getAutoMemoryRoot('/tmp/project')], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(launchEditor).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the second visible item with its numeric shortcut', async () => { + const onClose = vi.fn(); + render(); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: '2', sequence: '2' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockedSpawn).toHaveBeenCalledWith( + expectedFolderOpenCommand(), + [getAutoMemoryRoot('/tmp/project')], + expect.objectContaining({ detached: true, stdio: 'ignore' }), + ); + expect(onClose).toHaveBeenCalled(); + }); + + it('opens the managed memory index in an editor on headless Linux', async () => { + stubPlatform('linux'); + vi.stubEnv('DISPLAY', ''); + vi.stubEnv('WAYLAND_DISPLAY', ''); + vi.stubEnv('MIR_SOCKET', ''); + const onClose = vi.fn(); + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + + render(); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockedSpawn).not.toHaveBeenCalled(); + expect(mockedFs.access).toHaveBeenCalledWith( + path.join( + os.homedir(), + '.qwen-memory-test', + 'memories', + AUTO_MEMORY_INDEX_FILENAME, + ), + ); + expect(launchEditor).toHaveBeenCalledWith( + path.join( + os.homedir(), + '.qwen-memory-test', + 'memories', + AUTO_MEMORY_INDEX_FILENAME, + ), + ); + expect(onClose).toHaveBeenCalled(); + }); + + it('shows an error when opening a managed memory folder fails', async () => { + const onClose = vi.fn(); + mockedSpawn.mockImplementationOnce( + () => createMockSpawnChild('error', new Error('ENOENT')) as never, + ); + const { lastFrame } = render(); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(lastFrame()).toContain('ENOENT'); + expect(onClose).not.toHaveBeenCalled(); }); it('moves selection with Ctrl+N/P readline aliases', () => { @@ -272,6 +519,67 @@ describe('MemoryDialog', () => { expect(lastFrame()).toContain('› Confirm auto-skills before saving: off'); }); + it('keeps QWEN.md editor entries when managed memory is unavailable', async () => { + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + mockedUseConfig.mockReturnValue({ + getWorkingDir: vi.fn(() => '/tmp/project'), + getProjectRoot: vi.fn(() => '/tmp/project'), + getBareMode: vi.fn(() => true), + isSafeMode: vi.fn(() => false), + getManagedAutoMemoryEnabled: vi.fn(() => false), + getManagedAutoDreamEnabled: vi.fn(() => false), + getAutoSkillEnabled: vi.fn(() => false), + isManagedMemoryAvailable: vi.fn(() => false), + } as never); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('› 1. User memory'); + expect(lastFrame()).toContain('Saved in ~/.qwen/QWEN.md'); + expect(lastFrame()).toContain('2. Project memory'); + expect(lastFrame()).toContain('Saved in QWEN.md'); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: 'return' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(launchEditor).toHaveBeenCalledWith( + expect.stringMatching(/\/\.qwen\/QWEN\.md$/), + ); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + + it('opens the project QWEN.md editor entry when managed memory is unavailable', async () => { + const launchEditor = vi.fn(); + mockedUseLaunchEditor.mockReturnValue(launchEditor); + mockedUseConfig.mockReturnValue({ + getWorkingDir: vi.fn(() => '/tmp/project'), + getProjectRoot: vi.fn(() => '/tmp/project'), + getBareMode: vi.fn(() => true), + isSafeMode: vi.fn(() => false), + getManagedAutoMemoryEnabled: vi.fn(() => false), + getManagedAutoDreamEnabled: vi.fn(() => false), + getAutoSkillEnabled: vi.fn(() => false), + isManagedMemoryAvailable: vi.fn(() => false), + } as never); + + render(); + + const keypressHandler = mockedUseKeypress.mock.calls[0][0]; + await act(async () => { + keypressHandler({ name: '2', sequence: '2' } as never); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(launchEditor).toHaveBeenCalledWith('/tmp/project/QWEN.md'); + expect(mockedSpawn).not.toHaveBeenCalled(); + }); + it('reflects the persisted value when the dialog is reopened (remounted)', () => { // Emulate LoadedSettings: setValue writes through to the merged view, // exactly like the real saveSettings + recomputeMerged path. diff --git a/packages/cli/src/ui/components/MemoryDialog.tsx b/packages/cli/src/ui/components/MemoryDialog.tsx index c28bfb0068..650e7ef974 100644 --- a/packages/cli/src/ui/components/MemoryDialog.tsx +++ b/packages/cli/src/ui/components/MemoryDialog.tsx @@ -9,12 +9,14 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn } from 'node:child_process'; import { getAllGeminiMdFilenames, Storage, getAutoMemoryRoot, getAutoMemoryProjectStateDir, + getUserAutoMemoryRoot, + AUTO_MEMORY_INDEX_FILENAME, } from '@qwen-code/qwen-code-core'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; @@ -26,7 +28,9 @@ import { theme } from '../semantic-colors.js'; import { formatRelativeTime } from '../utils/formatters.js'; import { t } from '../../i18n/index.js'; -type MemoryDialogTarget = 'project' | 'global' | 'managed'; +type MemoryDialogTarget = 'project' | 'global'; +type MemoryDialogAction = 'file' | 'folder'; +const DISPLAY_ENV_VARS = ['DISPLAY', 'WAYLAND_DISPLAY', 'MIR_SOCKET'] as const; interface MemoryDialogProps { onClose: () => void; @@ -35,6 +39,7 @@ interface MemoryDialogProps { interface DialogItem { label: string; value: MemoryDialogTarget; + action: MemoryDialogAction; description?: string; } @@ -55,7 +60,7 @@ async function resolvePreferredMemoryFile( return path.join(dir, fallbackFilename); } -function openFolderPath(folderPath: string): void { +async function openFolderPath(folderPath: string): Promise { let command = 'xdg-open'; switch (process.platform) { @@ -70,21 +75,27 @@ function openFolderPath(folderPath: string): void { break; } - const needsShell = - process.platform === 'win32' && - (command.endsWith('.cmd') || command.endsWith('.bat')); - - const result = spawnSync(command, [folderPath], { - stdio: 'inherit', - shell: needsShell, + const child = spawn(command, [folderPath], { + detached: true, + stdio: 'ignore', }); - if (result.error) { - throw result.error; - } - if (typeof result.status === 'number' && result.status !== 0) { - throw new Error(`Folder opener exited with status ${result.status}`); + child.unref(); + + await new Promise((resolve, reject) => { + child.once('error', reject); + // Exit codes are intentionally not observed: the folder opener is + // fire-and-forget, and waiting for exit can block until the file manager + // closes. + child.once('spawn', () => resolve()); + }); +} + +function shouldOpenFolderPath(): boolean { + if (process.platform === 'darwin' || process.platform === 'win32') { + return true; } + return DISPLAY_ENV_VARS.some((key) => Boolean(process.env[key])); } async function ensureFileExists(filePath: string): Promise { @@ -155,17 +166,40 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { () => getAutoMemoryRoot(config.getProjectRoot()), [config], ); + const managedUserMemoryPath = useMemo(() => getUserAutoMemoryRoot(), []); const memoryStatePath = useMemo( () => getAutoMemoryProjectStateDir(config.getProjectRoot()), [config], ); - const items = useMemo( - () => [ + const items = useMemo(() => { + if (config.isManagedMemoryAvailable()) { + return [ + { + label: t('User memory'), + value: 'global', + action: 'folder', + description: t('Saved in {{path}}', { + path: formatDisplayPath(managedUserMemoryPath), + }), + }, + { + label: t('Project memory'), + value: 'project', + action: 'folder', + description: t('Saved in {{path}}', { + path: formatDisplayPath(managedMemoryPath), + }), + }, + ]; + } + + return [ { label: t('User memory'), value: 'global', + action: 'file', description: t('Saved in {{path}}', { path: formatDisplayPath(globalMemoryPath), }), @@ -173,19 +207,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { { label: t('Project memory'), value: 'project', + action: 'file', description: t('Saved in {{path}}', { path: path.relative(config.getWorkingDir(), projectMemoryPath) || path.basename(projectMemoryPath), }), }, - { - label: t('Open auto-memory folder'), - value: 'managed', - }, - ], - [config, globalMemoryPath, projectMemoryPath], - ); + ]; + }, [ + config, + globalMemoryPath, + managedMemoryPath, + managedUserMemoryPath, + projectMemoryPath, + ]); // Load lastDreamAt from meta.json useEffect(() => { @@ -219,8 +255,21 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { }, [lastDreamAt]); const resolveTargetPath = useCallback( - async (target: MemoryDialogTarget): Promise => { - switch (target) { + async (item: DialogItem): Promise => { + if (item.action === 'folder') { + switch (item.value) { + case 'global': + return managedUserMemoryPath; + case 'project': + return managedMemoryPath; + default: { + const _exhaustive: never = item.value; + return _exhaustive; + } + } + } + + switch (item.value) { case 'project': return resolvePreferredMemoryFile( config.getWorkingDir(), @@ -231,23 +280,29 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { Storage.getGlobalQwenDir(), getAllGeminiMdFilenames()[0] ?? 'QWEN.md', ); - case 'managed': - return managedMemoryPath; - default: - return managedMemoryPath; + default: { + const _exhaustive: never = item.value; + return _exhaustive; + } } }, - [config, managedMemoryPath], + [config, managedMemoryPath, managedUserMemoryPath], ); const handleSelect = useCallback( - async (target: MemoryDialogTarget) => { + async (item: DialogItem) => { try { setError(null); - const targetPath = await resolveTargetPath(target); - if (target === 'managed') { + const targetPath = await resolveTargetPath(item); + if (item.action === 'folder') { await fs.mkdir(targetPath, { recursive: true }); - openFolderPath(targetPath); + if (shouldOpenFolderPath()) { + await openFolderPath(targetPath); + } else { + const indexPath = path.join(targetPath, AUTO_MEMORY_INDEX_FILENAME); + await ensureFileExists(indexPath); + await launchEditor(indexPath); + } } else { await ensureFileExists(targetPath); await launchEditor(targetPath); @@ -395,15 +450,18 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { } if (key.name === 'return') { - void handleSelect(items[highlightedIndex]?.value ?? 'project'); + const selectedItem = items[highlightedIndex] ?? items[0]; + if (selectedItem) { + void handleSelect(selectedItem); + } return; } - if (key.sequence && /^[1-3]$/.test(key.sequence)) { + if (key.sequence && /^[1-9]$/.test(key.sequence)) { const nextIndex = Number(key.sequence) - 1; if (items[nextIndex]) { setHighlightedIndex(nextIndex); - void handleSelect(items[nextIndex].value); + void handleSelect(items[nextIndex]); } } }, @@ -483,7 +541,7 @@ export function MemoryDialog({ onClose }: MemoryDialogProps) { const isSelected = focusedSection === 'list' && index === highlightedIndex; return ( - + {isSelected ? '› ' : ' '} {index + 1}. {item.label} diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index c4ccc77b69..947a98b9ec 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -416,8 +416,7 @@ export class ReadFileTool extends BaseDeclarativeTool< type: 'integer', }, pages: { - description: - `Optional: For PDF files, the page range to extract as text (e.g., '1-5', '3', '10-20'). Pages are 1-indexed. Max ${PDF_MAX_PAGES_PER_READ} pages per request. Open-ended ranges like '3-' are not supported. Use this for large PDFs or when the model does not support native PDF input.`, + description: `Optional: For PDF files, the page range to extract as text (e.g., '1-5', '3', '10-20'). Pages are 1-indexed. Max ${PDF_MAX_PAGES_PER_READ} pages per request. Open-ended ranges like '3-' are not supported. Use this for large PDFs or when the model does not support native PDF input.`, type: 'string', }, }, diff --git a/packages/web-shell/client/components/StreamingStatus.test.tsx b/packages/web-shell/client/components/StreamingStatus.test.tsx index 69082a9567..40cdb91a7c 100644 --- a/packages/web-shell/client/components/StreamingStatus.test.tsx +++ b/packages/web-shell/client/components/StreamingStatus.test.tsx @@ -96,9 +96,12 @@ describe('StreamingStatus loading phrases', () => { it('hides the phrase but keeps the dynamic status when showPhrase is false', () => { pinPhraseSelection(); // A resolver that would otherwise supply a phrase must still be suppressed. - const container = render({ loadingPhrases: () => ['should not appear'] }, { - showPhrase: false, - }); + const container = render( + { loadingPhrases: () => ['should not appear'] }, + { + showPhrase: false, + }, + ); // The witty phrase (the "废话文学") is gone: no label span. expect(labelText(container)).toBeUndefined(); expect(container.textContent).not.toContain('should not appear'); From ba709c2c2c2694dd00764306db4c62c666ed2b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Thu, 9 Jul 2026 09:05:02 +0800 Subject: [PATCH 3/5] ci(autofix): Add single-target scheduler (#6547) * ci(autofix): add single-target scheduler * ci(autofix): handle failed PR checks in scheduler * ci(autofix): remove scheduler plan doc * ci(autofix): watermark failed PR checks * ci(autofix): harden failed check feedback * ci(autofix): count review-address failures * ci(autofix): include review-address failures in feedback --- .github/workflows/qwen-autofix.yml | 137 ++++++++++++++------ scripts/tests/qwen-autofix-workflow.test.js | 99 +++++++++++++- 2 files changed, 196 insertions(+), 40 deletions(-) diff --git a/.github/workflows/qwen-autofix.yml b/.github/workflows/qwen-autofix.yml index ee5d56cde3..ae46b4803a 100644 --- a/.github/workflows/qwen-autofix.yml +++ b/.github/workflows/qwen-autofix.yml @@ -8,14 +8,13 @@ name: 'Qwen Autofix' # The lifecycle is asynchronous — a PR is opened in one run and its review is # addressed in a later run once a reviewer has weighed in — so each scheduled # tick runs only the phase(s) that make sense, decided by the `route` job: -# • every 4h → review phase (sweep the bot's open PRs) -# • every 12h (00/12 UTC) → also the issue phase (locate + fix one new bug) -# • issues:labeled → issue phase when ready label, state, and sender match -# • pull_request_review → review phase (real-time feedback loop for bot PRs) -# • workflow_dispatch → force a phase, an issue, or a PR +# • every 10m → review phase; issue phase only if no PR needs work +# • issues:labeled → issue phase when ready label, state, and sender match +# • pull_request_review → review phase for submitted feedback on bot PRs +# • workflow_dispatch → force a phase, an issue, or a PR # # Every GitHub write (issue/PR comments, labels, branch push, PR create) goes -# through CI_DEV_BOT_PAT so the bot acts as a single identity, qwen-code-dev-bot. +# through CI_DEV_BOT_PAT so the bot acts as the configured autofix identity. # PAT label writes can emit issues:labeled events; the route guards below make # those runs exit unless the label, issue state, and ready label all match. on: @@ -26,8 +25,7 @@ on: types: - 'submitted' schedule: - - cron: '0 0,12 * * *' # Issue + review every 12h; must match route SCHEDULE check - - cron: '0 4,8,16,20 * * *' # Review only between issue runs + - cron: '*/10 * * * *' # Review first; issue fallback only when no PR needs work workflow_dispatch: inputs: phase: @@ -64,7 +62,7 @@ permissions: env: # Identity of the autofix bot. All open in-repo PRs authored by this bot are # eligible for the review phase — not limited to autofix/issue-* branches. - AUTOFIX_BOT: 'qwen-code-dev-bot' + AUTOFIX_BOT: "${{ vars.AUTOFIX_BOT_LOGIN || 'qwen-code-dev-bot' }}" # Branch-name prefix used by the issue phase when creating PRs. Also used # for duplicate-PR detection and issue-number extraction from branch names. BRANCH_PREFIX: 'autofix/issue-' @@ -77,7 +75,9 @@ env: TRUSTED_ASSOC: '["OWNER", "MEMBER", "COLLABORATOR"]' # Hard cap on automated review-address rounds per PR. After this the bot stops # and leaves the PR for a human. - MAX_ROUNDS: '3' + MAX_ROUNDS: '5' + # Do not claim more issues when too many existing autofix PRs are still open. + MAX_OPEN_AUTOFIX_PRS: '5' jobs: # --------------------------------------------------------------------------- @@ -147,8 +147,9 @@ jobs: if [[ "${EVENT_NAME}" == 'schedule' || "${EVENT_NAME}" == 'workflow_dispatch' ]]; then DO_REVIEW=true fi - # Must match the issue-phase cron string on the schedule trigger. - if [[ "${EVENT_NAME}" == 'schedule' && "${SCHEDULE}" == '0 0,12 * * *' ]]; then + # Scheduled runs scan review PRs first; issue-autofix runs only + # when review-scan reports no target. + if [[ "${EVENT_NAME}" == 'schedule' ]]; then DO_ISSUE=true fi # Real-time review triggers: only process in-repo bot PRs with @@ -248,9 +249,13 @@ jobs: # ISSUE PHASE — locate one maintainer-ready issue, fix it, open a PR. # =========================================================================== issue-autofix: - needs: 'route' + needs: ['route', 'review-scan'] if: |- - ${{ needs.route.outputs.do_issue == 'true' }} + ${{ + always() && + needs.route.outputs.do_issue == 'true' && + (github.event_name != 'schedule' || (needs.review-scan.result == 'success' && needs.review-scan.outputs.has_targets != 'true')) + }} runs-on: 'ubuntu-latest' timeout-minutes: 180 concurrency: @@ -371,6 +376,7 @@ jobs: FORCED_ISSUE: '${{ needs.route.outputs.issue_number || github.event.issue.number }}' run: |- mkdir -p "${WORKDIR}" + OPEN_AUTOFIX_PR_COUNT=0 if [[ -n "${FORCED_ISSUE}" ]]; then echo "🎯 Forced issue #${FORCED_ISSUE}" @@ -406,34 +412,51 @@ jobs: fi fi else - echo "🔍 Ready-for-agent issues (newest first)..." - if ! gh issue list --repo "${REPO}" \ + if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \ + --limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then + echo "::warning::Open autofix PR scan failed; proceeding without WIP-cap enforcement." + else + OPEN_AUTOFIX_PR_COUNT="$(jq --arg p "${BRANCH_PREFIX}" \ + '[.[] | select((.isCrossRepository != true) and ((.headRefName // "") | startswith($p)))] | length' \ + "${WORKDIR}/open-autofix-prs.json")" + fi + + if [[ "${OPEN_AUTOFIX_PR_COUNT}" -ge "${MAX_OPEN_AUTOFIX_PRS}" ]]; then + echo "⏭️ ${OPEN_AUTOFIX_PR_COUNT} open autofix PR(s) already exist; WIP limit is ${MAX_OPEN_AUTOFIX_PRS}; skipping issue fallback." + jq -n -c '[]' > "${WORKDIR}/candidates.json" + else + echo "🔍 Ready-for-agent issues (newest first)..." + if ! gh issue list --repo "${REPO}" \ --search "is:open is:issue label:${READY_FOR_AGENT_LABEL} label:${AUTOFIX_APPROVED_LABEL} ${AUTOFIX_ISSUE_EXCLUDES}" \ --limit 30 --json number,title,body,labels,createdAt,url \ > "${WORKDIR}/scan.json"; then - echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list." - jq -n -c '[]' > "${WORKDIR}/candidates.json" - else - if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \ - "${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then - echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list." + echo "::warning::Ready-for-agent issue scan failed; falling back to an empty candidate list." jq -n -c '[]' > "${WORKDIR}/candidates.json" + else + if ! jq -c '.[0:10] | map(. + {autofixTier: 1})' \ + "${WORKDIR}/scan.json" > "${WORKDIR}/candidates.json"; then + echo "::warning::Ready-for-agent result processing failed; falling back to an empty candidate list." + jq -n -c '[]' > "${WORKDIR}/candidates.json" + fi fi fi fi COUNT="$(jq length "${WORKDIR}/candidates.json")" if [[ "${COUNT}" -gt 0 ]]; then - if ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \ - --limit 100 --json number,headRefName > "${WORKDIR}/open-autofix-prs.json"; then + if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then + echo "ℹ️ Reusing open autofix PR scan for duplicate-PR annotation." + elif ! gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \ + --limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/open-autofix-prs.json"; then echo "::warning::Open autofix PR scan failed; candidates will proceed without duplicate-PR annotation." - else + fi + if [[ -s "${WORKDIR}/open-autofix-prs.json" ]]; then if ! jq -c --arg p "${BRANCH_PREFIX}" --slurpfile prs "${WORKDIR}/open-autofix-prs.json" ' ($prs[0] // []) as $prs | map( ($p + (.number | tostring)) as $branch | ( - first($prs[] | select((.headRefName // "") == $branch) | { + first($prs[] | select((.isCrossRepository != true) and ((.headRefName // "") == $branch)) | { number, headRefName }) // null @@ -804,8 +827,8 @@ jobs: if: |- ${{ steps.decision.outputs.go_issue != '' && needs.route.outputs.dry_run != 'true' }} env: - # CI_DEV_BOT_PAT (the qwen-code-dev-bot PAT) opens the PR as - # qwen-code-dev-bot. This is required: the default GITHUB_TOKEN is + # CI_DEV_BOT_PAT opens the PR as the configured autofix bot. This is + # required: the default GITHUB_TOKEN is # blocked from creating PRs ("GitHub Actions is not permitted to # create or approve pull requests"), and PRs it does create do not # trigger CI. The bot PAT clears both problems. @@ -813,7 +836,7 @@ jobs: ISSUE: '${{ steps.decision.outputs.go_issue }}' run: |- if [[ -z "${GITHUB_TOKEN}" ]]; then - echo '::error::CI_DEV_BOT_PAT is required to publish the PR as qwen-code-dev-bot.' + echo '::error::CI_DEV_BOT_PAT is required to publish the PR as the autofix bot.' exit 1 fi api_error_file="$(mktemp)" @@ -901,8 +924,8 @@ jobs: fi # =========================================================================== - # REVIEW PHASE (scan) — find every autofix PR with new, unaddressed feedback - # (or a base conflict) and emit them as a matrix. Cheap: GitHub API only. + # REVIEW PHASE (scan) — find one autofix PR with new, unaddressed feedback + # (or a base conflict) and emit it as a matrix. Cheap: GitHub API only. # =========================================================================== review-scan: needs: 'route' @@ -936,6 +959,7 @@ jobs: and ((.baseRefName // "") == "main"))' <<< "${META}")" if [[ "${OK}" != "true" ]]; then echo "❌ #${FORCED_PR} is not an open in-repo main-targeting PR owned by ${AUTOFIX_BOT}" + echo "targets=[]" >> "${GITHUB_OUTPUT}" echo "has_targets=false" >> "${GITHUB_OUTPUT}" exit 0 fi @@ -943,10 +967,10 @@ jobs: else gh pr list --repo "${REPO}" --state open --author "${AUTOFIX_BOT}" \ --base main \ - --limit 100 --json number,headRefName > "${WORKDIR}/bot-prs.json" - # gh pr list with --author on the same repo only returns in-repo PRs, - # and --base main limits to main-targeting PRs. - CANDIDATES="$(jq -r '.[] | .number' "${WORKDIR}/bot-prs.json")" + --limit 100 --json number,headRefName,isCrossRepository > "${WORKDIR}/bot-prs.json" + CANDIDATES="$(jq -r \ + '.[] | select(.isCrossRepository != true) | .number' \ + "${WORKDIR}/bot-prs.json")" fi TARGETS='[]' @@ -959,7 +983,18 @@ jobs: ISSUE="${PR}" fi HEAD_SHA="$(gh api "repos/${REPO}/pulls/${PR}" --jq '.head.sha')" - + CHECKS_JSON="$(gh pr view "${PR}" --repo "${REPO}" \ + --json statusCheckRollup --jq '.statusCheckRollup // []' 2> /dev/null || echo '[]')" + HAS_PENDING_CHECKS="$(jq -r ' + [ .[] + | select((.status // .state // "") | IN("QUEUED", "IN_PROGRESS", "PENDING", "WAITING", "REQUESTED")) + | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) ] + | length > 0 + ' <<< "${CHECKS_JSON}")" + if [[ "${HAS_PENDING_CHECKS}" == "true" ]]; then + echo "⏳ #${PR}: PR has pending checks; skipping until the current verification finishes" + continue + fi # Push watermark: the PR's last push. Feedback older than this was in # front of the agent on a previous round. PUSH_WM="$(gh api "repos/${REPO}/commits/${HEAD_SHA}" --jq '.commit.committer.date')" @@ -983,6 +1018,13 @@ jobs: echo "🚧 #${PR}: hit MAX_ROUNDS (${ROUND}/${MAX_ROUNDS}) — leaving for a human" continue fi + N_FAILED_CHECKS="$(jq --arg wm "${EFF_WM}" ' + [ .[] + | select((.conclusion // .state // "") | IN("FAILURE", "FAILED", "ERROR", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED")) + | select(((.workflowName // "") != "Qwen Autofix") or (((.name // "") | startswith("review-address")))) + | select((.completedAt // .updatedAt // "") > $wm) ] + | length + ' <<< "${CHECKS_JSON}")" gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate > "${WORKDIR}/rv.json" gh api "repos/${REPO}/pulls/${PR}/comments" --paginate > "${WORKDIR}/rc.json" @@ -1021,17 +1063,18 @@ jobs: HAS_CONFLICT='false' if [[ "${MERGEABLE}" == "CONFLICTING" ]]; then HAS_CONFLICT='true'; fi - if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then + if [[ "${N_REVIEWS}" -eq 0 && "${N_COMMENTS}" -eq 0 && "${N_ISSUE_COMMENTS}" -eq 0 && "${N_FAILED_CHECKS}" -eq 0 && "${HAS_CONFLICT}" != "true" ]]; then echo "✅ #${PR}: nothing new since ${EFF_WM} (conflict=${HAS_CONFLICT})" continue fi - echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) new, conflict=${HAS_CONFLICT}, round=${ROUND}" + echo "🔎 #${PR}: ${N_REVIEWS} review(s) + ${N_COMMENTS} inline + ${N_ISSUE_COMMENTS} issue comment(s) + ${N_FAILED_CHECKS} failed check(s) new, conflict=${HAS_CONFLICT}, round=${ROUND}" TARGETS="$(jq -c \ --arg pr "${PR}" --arg branch "${BRANCH}" --arg issue "${ISSUE}" \ --arg round "${ROUND}" --arg wm "${EFF_WM}" \ '. + [{pr: $pr, branch: $branch, issue: $issue, round: $round, watermark: $wm}]' \ <<< "${TARGETS}")" + break # one PR per scheduled scan done COUNT="$(jq 'length' <<< "${TARGETS}")" @@ -1181,6 +1224,9 @@ jobs: gh api "repos/${REPO}/pulls/${PR}/reviews" --paginate > "${WORKDIR}/rv.json" gh api "repos/${REPO}/pulls/${PR}/comments" --paginate > "${WORKDIR}/rc.json" gh api "repos/${REPO}/issues/${PR}/comments" --paginate > "${WORKDIR}/ic.json" + gh pr view "${PR}" --repo "${REPO}" \ + --json statusCheckRollup --jq '.statusCheckRollup // []' > "${WORKDIR}/checks.json" \ + 2> /dev/null || echo '[]' > "${WORKDIR}/checks.json" # Newest actionable feedback timestamp — stamped into the eval marker so # the next scan knows everything up to here has been considered. @@ -1200,7 +1246,11 @@ jobs: | select((.user.login // "") != $ab) | select(((.author_association // "") | IN($trust[])) or (.user.login // "") == $rb) | select((.body // "") | test("