From ea9d3d1e9589e6ef7252056a67b2b97b1e5804d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Mon, 10 Aug 2026 16:56:58 +0800 Subject: [PATCH 01/17] feat(cli): show loaded context files alongside the first prompt When context files (QWEN.md / context.fileName) are attached to the system prompt, surface a one-shot INFO line above the user's first prompt listing exactly which files were loaded, so users can verify discovery (e.g., catch typos in context.fileName) without digging into debug logs. Also shorten display paths for files under the user home to `~/...` in both the announcement and the /context detail breakdown. Fixes #5267 --- packages/cli/src/config/config.test.ts | 1 + packages/cli/src/ui/AppContainer.tsx | 64 +++++++++++++------ .../cli/src/ui/commands/contextCommand.ts | 9 ++- .../cli/src/ui/commands/directoryCommand.tsx | 2 + .../core/src/config/config.safe-mode.test.ts | 1 + packages/core/src/config/config.test.ts | 45 +++++++++++++ packages/core/src/config/config.ts | 51 ++++++++++----- .../core/src/utils/memoryDiscovery.test.ts | 64 ++++++++++++++++++- packages/core/src/utils/memoryDiscovery.ts | 42 +++++++++++- 9 files changed, 240 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c995ac20bc2..150d74d1bf4 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -238,6 +238,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { Promise.resolve({ memoryContent: extensionPaths?.join(',') || '', fileCount: extensionPaths?.length || 0, + contextFilePaths: extensionPaths || [], ruleCount: 0, conditionalRules: [], projectRoot: cwd || '/tmp', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index de5964922cd..093513e44f5 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -830,6 +830,11 @@ export const AppContainer = (props: AppContainerProps) => { * parent checkout. (PR #4174 review #3259975249.) */ const pendingWorktreeNoticeRef = useRef(null); + // One-shot announcement of the context files (QWEN.md / context.fileName) + // attached to the system prompt, shown alongside the first real prompt so + // users can verify discovery (e.g., catch typos in context.fileName) + // without digging into debug logs (#5267). + const contextFilesAnnouncedRef = useRef(false); const activeWorktree = useMemo( () => worktreeSession @@ -1939,6 +1944,7 @@ export const AppContainer = (props: AppContainerProps) => { if (config.isSafeMode()) { config.setUserMemory(''); config.setGeminiMdFileCount(0); + config.setContextFilePaths([]); config.setConditionalRulesRegistry( new ConditionalRulesRegistry([], config.getWorkingDir()), ); @@ -1961,27 +1967,33 @@ export const AppContainer = (props: AppContainerProps) => { Date.now(), ); try { - const { memoryContent, fileCount, conditionalRules, projectRoot } = - await loadHierarchicalGeminiMemory( - process.cwd(), - settings.merged.context?.loadFromIncludeDirectories - ? config.getWorkspaceContext().getDirectories() - : [], - config.getFileService(), - config.getExtensionContextFilePaths(), - config.isTrustedFolder(), - settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' - config.getContextRuleExcludes(), - { - loadReason: 'refresh', - onInstructionsLoaded: createInstructionsLoadedCallback(() => - config.getHookSystem(), - ), - }, - ); + const { + memoryContent, + fileCount, + contextFilePaths, + conditionalRules, + projectRoot, + } = await loadHierarchicalGeminiMemory( + process.cwd(), + settings.merged.context?.loadFromIncludeDirectories + ? config.getWorkspaceContext().getDirectories() + : [], + config.getFileService(), + config.getExtensionContextFilePaths(), + config.isTrustedFolder(), + settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree' + config.getContextRuleExcludes(), + { + loadReason: 'refresh', + onInstructionsLoaded: createInstructionsLoadedCallback(() => + config.getHookSystem(), + ), + }, + ); config.setUserMemory(memoryContent); config.setGeminiMdFileCount(fileCount); + config.setContextFilePaths(contextFilePaths); config.setConditionalRulesRegistry( new ConditionalRulesRegistry(conditionalRules, projectRoot), ); @@ -2461,6 +2473,22 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } + if ( + !contextFilesAnnouncedRef.current && + !isSlashCommand(userPromptText) + ) { + contextFilesAnnouncedRef.current = true; + const contextFilePaths = config.getContextFilePaths(); + if (contextFilePaths.length > 0) { + historyManager.addItem( + { + type: MessageType.INFO, + text: `Read context files: ${contextFilePaths.join(', ')}`, + }, + Date.now(), + ); + } + } const recoveredAgentsNotice = !isSlashCommand(userPromptText) && !isBtwCommand(userPromptText) ? config.consumePendingRecoveredAgentsNotice() diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 1bd2599b7b4..36138a97a31 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -27,9 +27,11 @@ import { ToolNames, buildSkillLlmContent, computeThresholds, + formatContextFileDisplayPath, type CompactionThresholds, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; +import * as path from 'node:path'; /** * Classify a token count against the three-tier compaction ladder. Mirrors @@ -84,7 +86,12 @@ function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { const filePath = match[1]!; const content = match[2]!; results.push({ - path: filePath, + // Marker paths are CWD-relative; shorten home-dir files to `~/...` + // so global memory files don't render as `../../..` chains. + path: formatContextFileDisplayPath( + path.resolve(process.cwd(), filePath), + process.cwd(), + ), tokens: estimateTokens(content), }); } diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 8b6e8872bd0..b27db975830 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -244,6 +244,7 @@ export const directoryCommand: SlashCommand = { const { memoryContent, fileCount, + contextFilePaths, conditionalRules, projectRoot, } = await loadServerHierarchicalMemory( @@ -258,6 +259,7 @@ export const directoryCommand: SlashCommand = { ); config.setUserMemory(memoryContent); config.setGeminiMdFileCount(fileCount); + config.setContextFilePaths(contextFilePaths); config.setConditionalRulesRegistry( new ConditionalRulesRegistry(conditionalRules, projectRoot), ); diff --git a/packages/core/src/config/config.safe-mode.test.ts b/packages/core/src/config/config.safe-mode.test.ts index f26c69ac8d9..5b0954c2fec 100644 --- a/packages/core/src/config/config.safe-mode.test.ts +++ b/packages/core/src/config/config.safe-mode.test.ts @@ -66,6 +66,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: '', fileCount: 0, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 445c7e0491f..bf3d598cb64 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -185,6 +185,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: '', fileCount: 0, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5215,6 +5216,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5265,6 +5267,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot, @@ -5334,6 +5337,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot, @@ -5372,6 +5376,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5405,6 +5410,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5450,6 +5456,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5495,6 +5502,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5522,6 +5530,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5540,6 +5549,34 @@ describe('Server Config (config.ts)', () => { ); }); + it('refreshHierarchicalMemory should expose loaded context file paths', async () => { + const config = new Config(baseParams); + + vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ + memoryContent: '--- Context from: QWEN.md ---\nProject rules', + fileCount: 1, + contextFilePaths: ['QWEN.md'], + ruleCount: 0, + conditionalRules: [], + projectRoot: '/tmp', + }); + + await config.refreshHierarchicalMemory(); + expect(config.getContextFilePaths()).toEqual(['QWEN.md']); + + vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ + memoryContent: '', + fileCount: 0, + contextFilePaths: [], + ruleCount: 0, + conditionalRules: [], + projectRoot: '/tmp', + }); + + await config.refreshHierarchicalMemory(); + expect(config.getContextFilePaths()).toEqual([]); + }); + it('refreshHierarchicalMemory should include appended auto-memory in the context warning estimate', async () => { const config = new Config({ ...baseParams, @@ -5549,6 +5586,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: 'short project rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5577,6 +5615,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: 'a'.repeat(800), fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5636,6 +5675,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: 'short project context', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6180,6 +6220,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6205,6 +6246,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6228,6 +6270,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6276,6 +6319,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6298,6 +6342,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3c024bc4f8b..b36ab88e9b3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1895,6 +1895,7 @@ export class Config { private autoMemoryPrompt = ''; private sdkMode: boolean; private geminiMdFileCount: number; + private loadedContextFilePaths: string[] = []; private conditionalRulesRegistry: ConditionalRulesRegistry | undefined; private readonly contextRuleExcludes: string[]; private approvalMode: ApprovalMode; @@ -3360,29 +3361,35 @@ export class Config { this.setUserMemory(''); this.autoMemoryPrompt = ''; this.setGeminiMdFileCount(0); + this.setContextFilePaths([]); this.conditionalRulesRegistry = new ConditionalRulesRegistry( [], this.getWorkingDir(), ); return; } - const { memoryContent, fileCount, conditionalRules, projectRoot } = - await loadServerHierarchicalMemory( - this.getWorkingDir(), - this.getMemoryDiscoveryDirectories(), - this.getFileService(), - this.getExtensionContextFilePaths(), - this.isTrustedFolder(), - this.getImportFormat(), - this.contextRuleExcludes, - { - explicitOnly: this.getBareMode(), - loadReason, - onInstructionsLoaded: createInstructionsLoadedCallback( - () => this.hookSystem, - ), - }, - ); + const { + memoryContent, + fileCount, + contextFilePaths, + conditionalRules, + projectRoot, + } = await loadServerHierarchicalMemory( + this.getWorkingDir(), + this.getMemoryDiscoveryDirectories(), + this.getFileService(), + this.getExtensionContextFilePaths(), + this.isTrustedFolder(), + this.getImportFormat(), + this.contextRuleExcludes, + { + explicitOnly: this.getBareMode(), + loadReason, + onInstructionsLoaded: createInstructionsLoadedCallback( + () => this.hookSystem, + ), + }, + ); if (this.isManagedMemoryAvailable()) { // User-level read is best-effort — an EACCES on // `~/.qwen/memories/MEMORY.md` must not strip the whole managed-memory @@ -3511,6 +3518,7 @@ export class Config { this.autoMemoryPrompt = ''; } this.setGeminiMdFileCount(fileCount); + this.setContextFilePaths(contextFilePaths); this.conditionalRulesRegistry = new ConditionalRulesRegistry( conditionalRules, projectRoot, @@ -5814,6 +5822,15 @@ export class Config { this.geminiMdFileCount = count; } + /** Display paths of the currently loaded context (memory) files. */ + getContextFilePaths(): string[] { + return this.loadedContextFilePaths; + } + + setContextFilePaths(paths: string[]): void { + this.loadedContextFilePaths = paths; + } + getArenaManager(): ArenaManager | null { return this.arenaManager; } diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 3630f4396b7..ca96bae5607 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -8,7 +8,10 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import * as fsPromises from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { loadServerHierarchicalMemory } from './memoryDiscovery.js'; +import { + loadServerHierarchicalMemory, + formatContextFileDisplayPath, +} from './memoryDiscovery.js'; import { setGeminiMdFilename, DEFAULT_CONTEXT_FILENAME, @@ -142,6 +145,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: '', fileCount: 0, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -180,6 +184,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: '', fileCount: 0, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -219,6 +224,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, explicitContextFile)} ---\nexplicit context\n--- End of Context from: ${path.relative(cwd, explicitContextFile)} ---`, fileCount: 1, + contextFilePaths: [path.relative(cwd, explicitContextFile)], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -242,6 +248,9 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---`, fileCount: 1, + contextFilePaths: [ + path.join('~', path.relative(homedir, defaultContextFile)), + ], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -268,6 +277,9 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, customContextFile)} ---\ncustom context content\n--- End of Context from: ${path.relative(cwd, customContextFile)} ---`, fileCount: 1, + contextFilePaths: [ + path.join('~', path.relative(homedir, customContextFile)), + ], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -298,6 +310,10 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, projectContextFile)} ---\nproject context content\n--- End of Context from: ${path.relative(cwd, projectContextFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdContextFile)} ---\ncwd context content\n--- End of Context from: ${path.relative(cwd, cwdContextFile)} ---`, fileCount: 2, + contextFilePaths: [ + path.relative(cwd, projectContextFile), + path.relative(cwd, cwdContextFile), + ], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -326,6 +342,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${customFilename} ---\nCWD custom memory\n--- End of Context from: ${customFilename} ---`, fileCount: 1, + contextFilePaths: [customFilename], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -353,6 +370,10 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, srcGeminiFile)} ---\nSrc directory memory\n--- End of Context from: ${path.relative(cwd, srcGeminiFile)} ---`, fileCount: 2, + contextFilePaths: [ + path.relative(cwd, projectRootGeminiFile), + path.relative(cwd, srcGeminiFile), + ], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -381,6 +402,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${DEFAULT_CONTEXT_FILENAME} ---\nCWD memory\n--- End of Context from: ${DEFAULT_CONTEXT_FILENAME} ---`, fileCount: 1, + contextFilePaths: [DEFAULT_CONTEXT_FILENAME], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -421,6 +443,12 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, defaultContextFile)} ---\ndefault context content\n--- End of Context from: ${path.relative(cwd, defaultContextFile)} ---\n\n--- Context from: ${path.relative(cwd, rootGeminiFile)} ---\nProject parent memory\n--- End of Context from: ${path.relative(cwd, rootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\nProject root memory\n--- End of Context from: ${path.relative(cwd, projectRootGeminiFile)} ---\n\n--- Context from: ${path.relative(cwd, cwdGeminiFile)} ---\nCWD memory\n--- End of Context from: ${path.relative(cwd, cwdGeminiFile)} ---`, fileCount: 4, + contextFilePaths: [ + path.join('~', path.relative(homedir, defaultContextFile)), + path.relative(cwd, rootGeminiFile), + path.relative(cwd, projectRootGeminiFile), + path.relative(cwd, cwdGeminiFile), + ], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -444,6 +472,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, extensionFilePath)} ---\nExtension memory content\n--- End of Context from: ${path.relative(cwd, extensionFilePath)} ---`, fileCount: 1, + contextFilePaths: [path.relative(cwd, extensionFilePath)], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -846,6 +875,7 @@ describe('loadServerHierarchicalMemory', () => { expect(result).toEqual({ memoryContent: `--- Context from: ${path.relative(cwd, includedFile)} ---\nincluded directory memory\n--- End of Context from: ${path.relative(cwd, includedFile)} ---`, fileCount: 1, + contextFilePaths: [path.relative(cwd, includedFile)], ruleCount: 0, conditionalRules: [], projectRoot: expect.any(String), @@ -1282,3 +1312,35 @@ describe('loadServerHierarchicalMemory', () => { }); }); }); + +describe('formatContextFileDisplayPath', () => { + it('returns CWD-relative paths for files inside the CWD tree', () => { + expect(formatContextFileDisplayPath('/proj/QWEN.md', '/proj')).toBe( + 'QWEN.md', + ); + expect(formatContextFileDisplayPath('/proj/sub/QWEN.md', '/proj')).toBe( + path.join('sub', 'QWEN.md'), + ); + }); + + it('shortens home-dir files outside the CWD tree to ~ paths', () => { + const home = os.homedir(); + expect( + formatContextFileDisplayPath( + path.join(home, '.qwen', 'QWEN.md'), + '/proj', + home, + ), + ).toBe(path.join('~', '.qwen', 'QWEN.md')); + }); + + it('keeps relative paths for files outside both CWD and home', () => { + expect( + formatContextFileDisplayPath('/other/QWEN.md', '/proj', '/home/u'), + ).toBe(path.join('..', 'other', 'QWEN.md')); + }); + + it('passes through non-absolute paths unchanged', () => { + expect(formatContextFileDisplayPath('QWEN.md', '/proj')).toBe('QWEN.md'); + }); +}); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 1301bab59b7..cef475ae5b5 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -314,6 +314,29 @@ async function readGeminiMdFiles( return results; } +/** + * Renders a context file path for display: relative to the CWD when the + * file is inside the CWD tree, otherwise a `~/...` shortcut when the file + * lives under the user home (instead of a long `../../..` chain). + */ +export function formatContextFileDisplayPath( + filePath: string, + currentWorkingDirectory: string, + userHomePath = homedir(), +): string { + if (!path.isAbsolute(filePath)) { + return filePath; + } + const relativePath = path.relative(currentWorkingDirectory, filePath); + if ( + relativePath.startsWith('..') && + filePath.startsWith(userHomePath + path.sep) + ) { + return path.join('~', path.relative(userHomePath, filePath)); + } + return relativePath; +} + function concatenateInstructions( instructionContents: GeminiFileContent[], // CWD is needed to resolve relative paths for display markers @@ -338,6 +361,11 @@ function concatenateInstructions( export interface LoadServerHierarchicalMemoryResponse { memoryContent: string; fileCount: number; + /** + * Display paths of the loaded context (memory) files, relative to CWD. + * Lets callers tell users which files were actually attached (see #5267). + */ + contextFilePaths: string[]; /** Number of baseline rules injected at session start. */ ruleCount: number; /** Conditional rules (with `paths:`) for turn-level lazy injection. */ @@ -476,6 +504,7 @@ export async function loadServerHierarchicalMemory( let combinedInstructions = ''; let fileCount = 0; + let contextFilePaths: string[] = []; if (filePaths.length > 0) { const loadReason = options.loadReason ?? 'session_start'; @@ -502,9 +531,17 @@ export async function loadServerHierarchicalMemory( ...getAllGeminiMdFilenames(), LOCAL_CONTEXT_FILENAME, ]); - fileCount = contentsWithPaths.filter((item) => + const memoryItems = contentsWithPaths.filter((item) => memoryFilenames.has(path.basename(item.filePath)), - ).length; + ); + fileCount = memoryItems.length; + contextFilePaths = memoryItems.map((item) => + formatContextFileDisplayPath( + item.filePath, + currentWorkingDirectory, + userHomePath, + ), + ); } // Load path-based context rules from .qwen/rules/ directories. @@ -531,6 +568,7 @@ export async function loadServerHierarchicalMemory( return { memoryContent, fileCount, + contextFilePaths, ruleCount, conditionalRules, projectRoot: effectiveRoot, From f2e90c6a7e3884f7a46b4b479c57b8c22886d722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Mon, 10 Aug 2026 20:20:07 +0800 Subject: [PATCH 02/17] fix(cli): address review feedback on context file visibility - Resolve memory-marker paths against the session working directory instead of process.cwd() in /context detail (ACP/daemon sessions) - Sanitize display paths with stripAnsiAndControl before they reach the terminal - Drop empty context files from the announced list to match concatenateInstructions' empty-content filter - Delegate home-dir shortening to tildeifyPath (with an optional home override for tests) instead of a second prefix-check implementation - Align the one-shot announcement guard with downstream input classification (trim, /btw, shell mode) - Add tests for the announcement latch, parseMemoryFiles ~ shortening, CWD-under-home and home-prefix-collision cases; use shared-volume fixtures so tests hold on Windows --- packages/cli/src/ui/AppContainer.test.tsx | 92 +++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 9 +- .../src/ui/commands/contextCommand.test.ts | 29 ++++++ .../cli/src/ui/commands/contextCommand.ts | 17 ++-- .../core/src/utils/memoryDiscovery.test.ts | 63 ++++++++++--- packages/core/src/utils/memoryDiscovery.ts | 40 ++++---- packages/core/src/utils/paths.ts | 6 +- 7 files changed, 217 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 51681d1e554..2be8c98a06b 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6148,6 +6148,98 @@ describe('AppContainer State Management', () => { ).toBe(false); }); }); + + describe('context files announcement (#5267)', () => { + const renderAnnouncementHarness = (contextFilePaths: string[]) => { + const addItem = vi.fn(); + mockedUseHistory.mockReturnValue({ + history: [], + addItem, + updateItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + truncateToItem: vi.fn(), + }); + vi.spyOn(mockConfig, 'getContextFilePaths').mockReturnValue( + contextFilePaths, + ); + render( + , + ); + return addItem; + }; + + const announcementCalls = (addItem: ReturnType) => + addItem.mock.calls.filter( + ([item]) => + item.type === MessageType.INFO && + typeof item.text === 'string' && + item.text.startsWith('Read context files:'), + ); + + it('announces loaded context files above the first real prompt, once', () => { + const addItem = renderAnnouncementHarness(['QWEN.md', '~/.qwen/QWEN.md']); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Read context files: QWEN.md, ~/.qwen/QWEN.md', + }), + expect.any(Number), + ); + + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); + + it('does not consume the latch on a leading slash command', () => { + const addItem = renderAnnouncementHarness(['QWEN.md']); + + capturedUIActions.handleFinalSubmit('/help', { + submittedPrompt: '/help', + }); + expect(announcementCalls(addItem)).toHaveLength(0); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); + + it('does not consume the latch on a leading /btw command', () => { + const addItem = renderAnnouncementHarness(['QWEN.md']); + + capturedUIActions.handleFinalSubmit('?btw side note', { + submittedPrompt: '?btw side note', + }); + expect(announcementCalls(addItem)).toHaveLength(0); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); + + it('emits nothing when no context files are loaded', () => { + const addItem = renderAnnouncementHarness([]); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(0); + }); + }); }); describe('dedupeNewestFirst', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 093513e44f5..f679d5a95e8 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2473,9 +2473,15 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } + // Mirror the downstream input classification (trim + btw + shell-mode + // exclusions) so the latch is only consumed by submissions that + // actually reach the model. + const trimmedPrompt = userPromptText.trim(); if ( !contextFilesAnnouncedRef.current && - !isSlashCommand(userPromptText) + !isSlashCommand(trimmedPrompt) && + !isBtwCommand(trimmedPrompt) && + !shellModeActive ) { contextFilesAnnouncedRef.current = true; const contextFilePaths = config.getContextFilePaths(); @@ -2711,6 +2717,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager, settings.merged.ui?.disableWorkflowKeywordTrigger, setBufferText, + shellModeActive, vimEnabled, ], ); diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index b806a6c527c..3088244fb7a 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -5,6 +5,8 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; import type { Config } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; import { @@ -56,6 +58,7 @@ function makeMockConfig(contextWindowSize = 32_000): Config { getAutoCompactThreshold: vi.fn(), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(true), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), } as unknown as Config; } @@ -87,6 +90,7 @@ describe('collectContextData (contextCommand)', () => { getAutoCompactThreshold: vi.fn(), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(true), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), } as unknown as Config; }); @@ -209,6 +213,7 @@ describe('collectContextData (contextCommand)', () => { getAutoCompactThreshold: vi.fn(), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(true), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), } as unknown as Config; const data = await collectContextData(config, true); @@ -254,6 +259,7 @@ describe('collectContextData (contextCommand)', () => { getAutoCompactThreshold: vi.fn(), getExperimentalZedIntegration: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(true), + getWorkingDir: vi.fn().mockReturnValue(process.cwd()), } as unknown as Config; const data = await collectContextData(config, true); @@ -281,6 +287,29 @@ describe('collectContextData (contextCommand)', () => { expect(data.memoryFiles[0].path).toBe(t('auto memory')); expect(data.memoryFiles[0].tokens).toBeGreaterThan(0); }); + + it('shortens home-dir memory marker paths to ~ in the breakdown', async () => { + // Memory markers store paths relative to the session working directory; + // global files must render as `~/...` instead of `../../..` chains. + const workingDir = process.cwd(); + const globalFile = path.join(os.homedir(), '.qwen', 'QWEN.md'); + const markerPath = path.relative(workingDir, globalFile); + const memory = + `--- Context from: ${markerPath} ---\n` + + `global rules\n` + + `--- End of Context from: ${markerPath} ---`; + const config = { + ...makeMockConfig(), + getUserMemory: vi.fn().mockReturnValue(memory), + getAutoMemoryPrompt: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(workingDir), + } as unknown as Config; + + const data = await collectContextData(config, true); + + expect(data.memoryFiles).toHaveLength(1); + expect(data.memoryFiles[0].path).toBe(path.join('~', '.qwen', 'QWEN.md')); + }); }); describe('/context shows three-tier thresholds', () => { diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 36138a97a31..58f3948c342 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -73,7 +73,10 @@ function estimateTokens(text: string): number { * Parse concatenated memory content into individual file entries. * Memory content format: "--- Context from: ---\n\n--- End of Context from: ---" */ -function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { +function parseMemoryFiles( + memoryContent: string, + workingDir: string, +): ContextMemoryDetail[] { if (!memoryContent || memoryContent.trim().length === 0) return []; const results: ContextMemoryDetail[] = []; @@ -86,11 +89,13 @@ function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { const filePath = match[1]!; const content = match[2]!; results.push({ - // Marker paths are CWD-relative; shorten home-dir files to `~/...` - // so global memory files don't render as `../../..` chains. + // Marker paths are relative to the session working directory (where + // memory discovery ran, which may differ from process.cwd() in + // ACP/daemon-served sessions); shorten home-dir files to `~/...` so + // global memory files don't render as `../../..` chains. path: formatContextFileDisplayPath( - path.resolve(process.cwd(), filePath), - process.cwd(), + path.resolve(workingDir, filePath), + workingDir, ), tokens: estimateTokens(content), }); @@ -178,7 +183,7 @@ export async function collectContextData( } const memoryContent = config.getUserMemory(); - const memoryFiles = parseMemoryFiles(memoryContent); + const memoryFiles = parseMemoryFiles(memoryContent, config.getWorkingDir()); const autoMemoryPrompt = config.getAutoMemoryPrompt(); if (autoMemoryPrompt) { memoryFiles.push({ diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index ca96bae5607..5bf51cdfa60 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -1314,33 +1314,70 @@ describe('loadServerHierarchicalMemory', () => { }); describe('formatContextFileDisplayPath', () => { + // Fixtures share one volume (os.tmpdir()) so `..` relationships hold on + // every platform; POSIX literals like '/proj' behave differently under + // path.win32 and would fail the Windows merge-queue gate. + const root = os.tmpdir(); + const proj = path.join(root, 'proj'); + const other = path.join(root, 'other'); + const home = path.join(root, 'u'); + const siblingHome = path.join(root, 'u2'); + + beforeEach(() => { + vi.mocked(os.homedir).mockReturnValue(home); + }); + it('returns CWD-relative paths for files inside the CWD tree', () => { - expect(formatContextFileDisplayPath('/proj/QWEN.md', '/proj')).toBe( + expect(formatContextFileDisplayPath(path.join(proj, 'QWEN.md'), proj)).toBe( 'QWEN.md', ); - expect(formatContextFileDisplayPath('/proj/sub/QWEN.md', '/proj')).toBe( - path.join('sub', 'QWEN.md'), - ); + expect( + formatContextFileDisplayPath(path.join(proj, 'sub', 'QWEN.md'), proj), + ).toBe(path.join('sub', 'QWEN.md')); }); it('shortens home-dir files outside the CWD tree to ~ paths', () => { - const home = os.homedir(); + expect( + formatContextFileDisplayPath(path.join(home, '.qwen', 'QWEN.md'), proj), + ).toBe(path.join('~', '.qwen', 'QWEN.md')); + }); + + it('prefers CWD-relative paths for projects under the home dir', () => { + const projUnderHome = path.join(home, 'proj'); expect( formatContextFileDisplayPath( - path.join(home, '.qwen', 'QWEN.md'), - '/proj', - home, + path.join(projUnderHome, 'QWEN.md'), + projUnderHome, ), - ).toBe(path.join('~', '.qwen', 'QWEN.md')); + ).toBe('QWEN.md'); + }); + + it('does not tildeify sibling directories sharing the home prefix', () => { + const file = path.join(siblingHome, 'proj', 'QWEN.md'); + expect(formatContextFileDisplayPath(file, proj)).toBe( + path.relative(proj, file), + ); }); it('keeps relative paths for files outside both CWD and home', () => { - expect( - formatContextFileDisplayPath('/other/QWEN.md', '/proj', '/home/u'), - ).toBe(path.join('..', 'other', 'QWEN.md')); + const file = path.join(other, 'QWEN.md'); + expect(formatContextFileDisplayPath(file, proj)).toBe( + path.relative(proj, file), + ); }); it('passes through non-absolute paths unchanged', () => { - expect(formatContextFileDisplayPath('QWEN.md', '/proj')).toBe('QWEN.md'); + expect(formatContextFileDisplayPath('QWEN.md', proj)).toBe('QWEN.md'); + }); + + it('strips ANSI escapes and control characters from display paths', () => { + // CSI parameter bytes span 0x30-0x3F, so ESC[2J consumes the 'b' too; + // BEL is removed by the residual control-char pass. + expect( + formatContextFileDisplayPath( + path.join(proj, 'a\u001b[2Jb\u0007.md'), + proj, + ), + ).toBe('a.md'); }); }); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index cef475ae5b5..a0b1a19909f 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -14,7 +14,8 @@ import { } from '../memory/const.js'; import type { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { processImports } from './memoryImportProcessor.js'; -import { isSubpath, QWEN_DIR } from './paths.js'; +import { isSubpath, QWEN_DIR, tildeifyPath } from './paths.js'; +import { stripAnsiAndControl } from './textUtils.js'; import { Storage } from '../config/storage.js'; import { createDebugLogger } from './debugLogger.js'; import { findProjectRoot } from './projectRoot.js'; @@ -317,24 +318,26 @@ async function readGeminiMdFiles( /** * Renders a context file path for display: relative to the CWD when the * file is inside the CWD tree, otherwise a `~/...` shortcut when the file - * lives under the user home (instead of a long `../../..` chain). + * lives under the user home (instead of a long `../../..` chain). Output + * is sanitized because directory names are attacker-influenceable. */ export function formatContextFileDisplayPath( filePath: string, currentWorkingDirectory: string, - userHomePath = homedir(), ): string { if (!path.isAbsolute(filePath)) { - return filePath; + return stripAnsiAndControl(filePath); } const relativePath = path.relative(currentWorkingDirectory, filePath); - if ( - relativePath.startsWith('..') && - filePath.startsWith(userHomePath + path.sep) - ) { - return path.join('~', path.relative(userHomePath, filePath)); + if (relativePath.startsWith('..')) { + // Pass homedir() explicitly: tildeifyPath's own lookup goes through a + // node:os default import that this module's test mock doesn't reach. + const tildeified = tildeifyPath(filePath, homedir()); + if (tildeified !== filePath) { + return stripAnsiAndControl(tildeified); + } } - return relativePath; + return stripAnsiAndControl(relativePath); } function concatenateInstructions( @@ -535,13 +538,16 @@ export async function loadServerHierarchicalMemory( memoryFilenames.has(path.basename(item.filePath)), ); fileCount = memoryItems.length; - contextFilePaths = memoryItems.map((item) => - formatContextFileDisplayPath( - item.filePath, - currentWorkingDirectory, - userHomePath, - ), - ); + // Mirror concatenateInstructions' empty-content filter: only files whose + // content actually reached the system prompt count as "attached". + contextFilePaths = memoryItems + .filter( + (item) => + typeof item.content === 'string' && item.content.trim().length > 0, + ) + .map((item) => + formatContextFileDisplayPath(item.filePath, currentWorkingDirectory), + ); } // Load path-based context rules from .qwen/rules/ directories. diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index a90c9adb9ed..e6b116fb3ef 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -65,10 +65,12 @@ const UNESCAPE_REGEX = (() => { /** * Replaces the home directory with a tilde. * @param filePath - The path to tildeify. + * @param homeOverride - Optional home directory override (for callers/tests + * that track home themselves instead of relying on os.homedir()). * @returns The tildeified path. */ -export function tildeifyPath(filePath: string): string { - const rawHomeDir = os.homedir(); +export function tildeifyPath(filePath: string, homeOverride?: string): string { + const rawHomeDir = homeOverride ?? os.homedir(); if (!rawHomeDir) { return filePath; } From 7b04b367fbb7b5826bfd5d32ae6988204d13ca66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 11 Aug 2026 10:38:56 +0800 Subject: [PATCH 03/17] fix(cli): address second-round review feedback on context file visibility - Skip the announcement latch for blank submissions (dropped by the message queue before reaching the model) - Treat Windows cross-drive relative results (absolute paths) as outside the CWD tree so home-dir files still get `~` shortening - Extract the hasAttachedContent predicate shared by concatenateInstructions and contextFilePaths so "displayed = attached" holds by construction - Thread the loader's resolved userHomePath through formatContextFileDisplayPath into tildeifyPath so discovery and display agree on the home directory - Pin ordering (announcement precedes submission admission), add a whitespace-only-file filter test, exercise workingDir != cwd in the /context detail test, and cover the /directory add reload wiring --- packages/cli/src/ui/AppContainer.test.tsx | 45 ++++++++++++++++--- packages/cli/src/ui/AppContainer.tsx | 7 +-- .../src/ui/commands/contextCommand.test.ts | 7 +-- .../src/ui/commands/directoryCommand.test.tsx | 40 +++++++++++++++++ .../core/src/utils/memoryDiscovery.test.ts | 18 ++++++++ packages/core/src/utils/memoryDiscovery.ts | 38 +++++++++------- packages/core/src/utils/paths.ts | 5 ++- 7 files changed, 131 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 2be8c98a06b..a23d5ca67df 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6152,6 +6152,7 @@ describe('AppContainer State Management', () => { describe('context files announcement (#5267)', () => { const renderAnnouncementHarness = (contextFilePaths: string[]) => { const addItem = vi.fn(); + const enqueueMessage = vi.fn(); mockedUseHistory.mockReturnValue({ history: [], addItem, @@ -6160,6 +6161,16 @@ describe('AppContainer State Management', () => { loadHistory: vi.fn(), truncateToItem: vi.fn(), }); + mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), + messageQueue: [], + addMessage: enqueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextTurn: vi.fn().mockReturnValue(null), + }); vi.spyOn(mockConfig, 'getContextFilePaths').mockReturnValue( contextFilePaths, ); @@ -6171,7 +6182,7 @@ describe('AppContainer State Management', () => { initializationResult={mockInitResult} />, ); - return addItem; + return { addItem, enqueueMessage }; }; const announcementCalls = (addItem: ReturnType) => @@ -6183,7 +6194,10 @@ describe('AppContainer State Management', () => { ); it('announces loaded context files above the first real prompt, once', () => { - const addItem = renderAnnouncementHarness(['QWEN.md', '~/.qwen/QWEN.md']); + const { addItem, enqueueMessage } = renderAnnouncementHarness([ + 'QWEN.md', + '~/.qwen/QWEN.md', + ]); capturedUIActions.handleFinalSubmit('hello', { submittedPrompt: 'hello', @@ -6196,6 +6210,12 @@ describe('AppContainer State Management', () => { }), expect.any(Number), ); + // The INFO item must be added before the submission is admitted, so it + // renders above the prompt. + expect(enqueueMessage).toHaveBeenCalled(); + expect(addItem.mock.invocationCallOrder[0]).toBeLessThan( + enqueueMessage.mock.invocationCallOrder[0], + ); capturedUIActions.handleFinalSubmit('again', { submittedPrompt: 'again', @@ -6204,7 +6224,7 @@ describe('AppContainer State Management', () => { }); it('does not consume the latch on a leading slash command', () => { - const addItem = renderAnnouncementHarness(['QWEN.md']); + const { addItem } = renderAnnouncementHarness(['QWEN.md']); capturedUIActions.handleFinalSubmit('/help', { submittedPrompt: '/help', @@ -6218,7 +6238,7 @@ describe('AppContainer State Management', () => { }); it('does not consume the latch on a leading /btw command', () => { - const addItem = renderAnnouncementHarness(['QWEN.md']); + const { addItem } = renderAnnouncementHarness(['QWEN.md']); capturedUIActions.handleFinalSubmit('?btw side note', { submittedPrompt: '?btw side note', @@ -6232,13 +6252,28 @@ describe('AppContainer State Management', () => { }); it('emits nothing when no context files are loaded', () => { - const addItem = renderAnnouncementHarness([]); + const { addItem } = renderAnnouncementHarness([]); capturedUIActions.handleFinalSubmit('hello', { submittedPrompt: 'hello', }); expect(announcementCalls(addItem)).toHaveLength(0); }); + + it('does not consume the latch on a whitespace-only prompt', () => { + const { addItem } = renderAnnouncementHarness(['QWEN.md']); + + // Blank submissions are dropped downstream and never reach the model. + capturedUIActions.handleFinalSubmit(' ', { + submittedPrompt: ' ', + }); + expect(announcementCalls(addItem)).toHaveLength(0); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f679d5a95e8..d0e91de2cf7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2473,11 +2473,12 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } - // Mirror the downstream input classification (trim + btw + shell-mode - // exclusions) so the latch is only consumed by submissions that - // actually reach the model. + // Mirror the downstream input classification (trim + blank + btw + + // shell-mode exclusions) so the latch is only consumed by submissions + // that actually reach the model. const trimmedPrompt = userPromptText.trim(); if ( + trimmedPrompt.length > 0 && !contextFilesAnnouncedRef.current && !isSlashCommand(trimmedPrompt) && !isBtwCommand(trimmedPrompt) && diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 3088244fb7a..79d5418c3d0 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -289,9 +289,10 @@ describe('collectContextData (contextCommand)', () => { }); it('shortens home-dir memory marker paths to ~ in the breakdown', async () => { - // Memory markers store paths relative to the session working directory; - // global files must render as `~/...` instead of `../../..` chains. - const workingDir = process.cwd(); + // Memory markers store paths relative to the session working directory, + // which in ACP/daemon-served sessions differs from process.cwd(); global + // files must render as `~/...` instead of `../../..` chains. + const workingDir = path.join(os.tmpdir(), 'context-session-dir'); const globalFile = path.join(os.homedir(), '.qwen', 'QWEN.md'); const markerPath = path.relative(workingDir, globalFile); const memory = diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index e4d0e29bc64..81e34428831 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { directoryCommand, getDirPathCompletions } from './directoryCommand.js'; import { expandHomeDir, + loadServerHierarchicalMemory, type Config, type WorkspaceContext, } from '@qwen-code/qwen-code-core'; @@ -17,6 +18,15 @@ import * as os from 'node:os'; import * as path from 'node:path'; import * as fs from 'node:fs'; +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + loadServerHierarchicalMemory: vi.fn(), + }; +}); + describe('directoryCommand', () => { let mockContext: CommandContext; let mockConfig: Config; @@ -239,6 +249,36 @@ describe('directoryCommand', () => { ); }); + it('refreshes context file paths when reloading memory from include directories', async () => { + vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ + memoryContent: 'reloaded memory', + fileCount: 2, + contextFilePaths: ['a/QWEN.md', '~/.qwen/QWEN.md'], + ruleCount: 0, + conditionalRules: [], + projectRoot: '/test/dir', + }); + mockConfig.shouldLoadMemoryFromIncludeDirectories = () => true; + mockConfig.getFolderTrust = vi.fn().mockReturnValue(true); + mockConfig.getContextRuleExcludes = vi.fn().mockReturnValue([]); + mockConfig.setContextFilePaths = vi.fn(); + mockConfig.setConditionalRulesRegistry = vi.fn(); + mockContext.ui.setGeminiMdFileCount = vi.fn(); + + if (!addCommand?.action) throw new Error('No action'); + await addCommand.action( + mockContext, + path.normalize('/home/user/new-project'), + ); + + expect(loadServerHierarchicalMemory).toHaveBeenCalled(); + expect(mockConfig.setUserMemory).toHaveBeenCalledWith('reloaded memory'); + expect(mockConfig.setContextFilePaths).toHaveBeenCalledWith([ + 'a/QWEN.md', + '~/.qwen/QWEN.md', + ]); + }); + it('should not persist directories skipped by the workspace context', async () => { const skippedPath = path.normalize('/home/user/missing-project'); vi.mocked(mockWorkspaceContext.addDirectory).mockImplementation( diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index 5bf51cdfa60..a5d431a2811 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -479,6 +479,24 @@ describe('loadServerHierarchicalMemory', () => { }); }); + it('counts but does not announce whitespace-only context files', async () => { + await createTestFile(path.join(cwd, DEFAULT_CONTEXT_FILENAME), ' \n\t '); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [], + DEFAULT_FOLDER_TRUST, + ); + + // The file is discovered, but its blank content never reaches the system + // prompt, so it must not be announced as attached. + expect(result.fileCount).toBe(1); + expect(result.memoryContent).toBe(''); + expect(result.contextFilePaths).toEqual([]); + }); + it('notifies when startup instruction files are loaded', async () => { const globalFile = await createTestFile( path.join(homedir, QWEN_DIR, DEFAULT_CONTEXT_FILENAME), diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index a0b1a19909f..dc2c63b984d 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -324,15 +324,17 @@ async function readGeminiMdFiles( export function formatContextFileDisplayPath( filePath: string, currentWorkingDirectory: string, + // Same home the loader used for discovery, so display and discovery agree. + userHomePath = homedir(), ): string { if (!path.isAbsolute(filePath)) { return stripAnsiAndControl(filePath); } const relativePath = path.relative(currentWorkingDirectory, filePath); - if (relativePath.startsWith('..')) { - // Pass homedir() explicitly: tildeifyPath's own lookup goes through a - // node:os default import that this module's test mock doesn't reach. - const tildeified = tildeifyPath(filePath, homedir()); + // On Windows, cross-drive targets come back as absolute paths (no common + // root) instead of `..` chains; treat them as outside the CWD tree too. + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + const tildeified = tildeifyPath(filePath, userHomePath); if (tildeified !== filePath) { return stripAnsiAndControl(tildeified); } @@ -340,24 +342,27 @@ export function formatContextFileDisplayPath( return stripAnsiAndControl(relativePath); } +// The attachment rule for the system prompt: only non-blank string content +// reaches it. Shared by concatenateInstructions and contextFilePaths so the +// "displayed = attached" property holds by construction. +function hasAttachedContent(item: GeminiFileContent): boolean { + return typeof item.content === 'string' && item.content.trim().length > 0; +} + function concatenateInstructions( instructionContents: GeminiFileContent[], // CWD is needed to resolve relative paths for display markers currentWorkingDirectoryForDisplay: string, ): string { return instructionContents - .filter((item) => typeof item.content === 'string') + .filter(hasAttachedContent) .map((item) => { const trimmedContent = (item.content as string).trim(); - if (trimmedContent.length === 0) { - return null; - } const displayPath = path.isAbsolute(item.filePath) ? path.relative(currentWorkingDirectoryForDisplay, item.filePath) : item.filePath; return `--- Context from: ${displayPath} ---\n${trimmedContent}\n--- End of Context from: ${displayPath} ---`; }) - .filter((block): block is string => block !== null) .join('\n\n'); } @@ -538,15 +543,16 @@ export async function loadServerHierarchicalMemory( memoryFilenames.has(path.basename(item.filePath)), ); fileCount = memoryItems.length; - // Mirror concatenateInstructions' empty-content filter: only files whose - // content actually reached the system prompt count as "attached". + // Only files whose content actually reached the system prompt count as + // "attached" (see hasAttachedContent). contextFilePaths = memoryItems - .filter( - (item) => - typeof item.content === 'string' && item.content.trim().length > 0, - ) + .filter(hasAttachedContent) .map((item) => - formatContextFileDisplayPath(item.filePath, currentWorkingDirectory), + formatContextFileDisplayPath( + item.filePath, + currentWorkingDirectory, + userHomePath, + ), ); } diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index e6b116fb3ef..7810c9f5123 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -65,8 +65,9 @@ const UNESCAPE_REGEX = (() => { /** * Replaces the home directory with a tilde. * @param filePath - The path to tildeify. - * @param homeOverride - Optional home directory override (for callers/tests - * that track home themselves instead of relying on os.homedir()). + * @param homeOverride - Optional home directory override for callers that + * track home themselves (e.g. memory discovery resolves it at load time so + * display and discovery agree). * @returns The tildeified path. */ export function tildeifyPath(filePath: string, homeOverride?: string): string { From 31c3e988c1873c14aa75022e261adcf869f87a6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 11 Aug 2026 13:05:16 +0800 Subject: [PATCH 04/17] fix(cli): address third-round review feedback on context file visibility - Consume the one-shot latch on model-invocable slash commands: skills and MCP prompts are expanded into a submit_prompt that reaches the model, so excluding all slash commands deferred the announcement to a later plain prompt (or hid it entirely in skill-only sessions). - Build contextFilePaths from every attached file, not just memory-named ones, so the announcement matches what concatenateInstructions injects (extension context files with custom basenames were attached but unannounced while /context detail listed them). - Correct the contextFilePaths JSDoc: entries are display paths (CWD-relative or ~/... shortcuts), not paths to resolve against the CWD. - Tests: model-invocable skill first turn consumes the latch; shell-mode submissions do not; extension files with custom basenames are announced. --- packages/cli/src/ui/AppContainer.test.tsx | 55 +++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 8 ++- .../core/src/utils/memoryDiscovery.test.ts | 23 ++++++++ packages/core/src/utils/memoryDiscovery.ts | 11 ++-- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index a23d5ca67df..05f1c43b758 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -81,6 +81,7 @@ import { StreamingState, ToolCallStatus, } from './types.js'; +import { CommandKind } from './commands/types.js'; import type { RestoreOption } from './components/RewindSelector.js'; import { Box, measureElement } from 'ink'; import type { Content } from '@google/genai'; @@ -6274,6 +6275,60 @@ describe('AppContainer State Management', () => { }); expect(announcementCalls(addItem)).toHaveLength(1); }); + + it('consumes the latch on a model-invocable slash command (skills)', () => { + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: vi.fn(), + slashCommands: [ + { + name: 'feat-dev', + description: 'Feature development workflow', + kind: CommandKind.SKILL, + modelInvocable: true, + action: vi.fn(), + }, + ], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + const { addItem } = renderAnnouncementHarness(['QWEN.md']); + + // Skills are expanded into a submit_prompt that reaches the model, so + // the announcement must attach to this turn, not a later plain prompt. + capturedUIActions.handleFinalSubmit('/feat-dev implement X', { + submittedPrompt: '/feat-dev implement X', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); + + it('does not consume the latch while shell mode is active', () => { + const { addItem } = renderAnnouncementHarness(['QWEN.md']); + + // Shell-mode submissions are intercepted by the shell processor and + // never reach the model. + act(() => { + capturedUIActions.setShellModeActive(true); + }); + capturedUIActions.handleFinalSubmit('ls -la', { + submittedPrompt: 'ls -la', + }); + expect(announcementCalls(addItem)).toHaveLength(0); + + act(() => { + capturedUIActions.setShellModeActive(false); + }); + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index d0e91de2cf7..b3844eb7814 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2475,12 +2475,16 @@ export const AppContainer = (props: AppContainerProps) => { } // Mirror the downstream input classification (trim + blank + btw + // shell-mode exclusions) so the latch is only consumed by submissions - // that actually reach the model. + // that actually reach the model. Model-invocable slash commands + // (skills, MCP prompts) are expanded into a submit_prompt and sent to + // the model, so they consume the latch like plain prompts. const trimmedPrompt = userPromptText.trim(); if ( trimmedPrompt.length > 0 && !contextFilesAnnouncedRef.current && - !isSlashCommand(trimmedPrompt) && + (!isSlashCommand(trimmedPrompt) || + parseSlashCommand(trimmedPrompt, slashCommands).commandToExecute + ?.modelInvocable === true) && !isBtwCommand(trimmedPrompt) && !shellModeActive ) { diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index a5d431a2811..ee0359ea6c2 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -479,6 +479,29 @@ describe('loadServerHierarchicalMemory', () => { }); }); + it('announces extension context files with custom basenames', async () => { + const extensionFilePath = await createTestFile( + path.join(testRootDir, 'extensions/ext1/system-prompt.md'), + 'Extension custom context content', + ); + + const result = await loadServerHierarchicalMemory( + cwd, + [], + new FileDiscoveryService(projectRoot), + [extensionFilePath], + DEFAULT_FOLDER_TRUST, + ); + + // The file is attached by concatenateInstructions even though its + // basename is not a configured memory filename, so it must be announced. + expect(result.fileCount).toBe(0); + expect(result.memoryContent).toContain('Extension custom context content'); + expect(result.contextFilePaths).toEqual([ + path.relative(cwd, extensionFilePath), + ]); + }); + it('counts but does not announce whitespace-only context files', async () => { await createTestFile(path.join(cwd, DEFAULT_CONTEXT_FILENAME), ' \n\t '); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index dc2c63b984d..3987cb2633d 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -370,7 +370,9 @@ export interface LoadServerHierarchicalMemoryResponse { memoryContent: string; fileCount: number; /** - * Display paths of the loaded context (memory) files, relative to CWD. + * Display paths of the loaded context (memory) files: CWD-relative when + * inside the CWD tree, `~/...` shortcuts for files under the user home. + * Display-only — do not resolve them against the CWD. * Lets callers tell users which files were actually attached (see #5267). */ contextFilePaths: string[]; @@ -543,9 +545,10 @@ export async function loadServerHierarchicalMemory( memoryFilenames.has(path.basename(item.filePath)), ); fileCount = memoryItems.length; - // Only files whose content actually reached the system prompt count as - // "attached" (see hasAttachedContent). - contextFilePaths = memoryItems + // Announce every file whose content actually reached the system prompt + // (see hasAttachedContent) — not just memory-named files — so the list + // matches what concatenateInstructions attached. + contextFilePaths = contentsWithPaths .filter(hasAttachedContent) .map((item) => formatContextFileDisplayPath( From 3e29a5e0c5216b0819129dd5764516af7ca6d28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 11 Aug 2026 16:51:18 +0800 Subject: [PATCH 05/17] fix(cli): address fourth-round review feedback on context file visibility - Replace the order-dependent shell-mode test with a pure predicate (consumesContextAnnouncementLatch) and hermetic unit tests: the old test failed deterministically when run in isolation because the React harness state toggle depended on earlier tests' side effects. - Keep latch consumption correct while shell mode is active for slash commands, which route before the shell intercept: model-invocable ones (skills, MCP prompts) still consume it. - Consume the latch only when something was actually announced, so files attached later in the session still get their one-shot notice. - Skip latch consumption for queued (deferUntilIdle) submissions, which are admitted only after the queue drains. - Re-arm the latch on same-process session switches (/clear) via an effect keyed on the session id. - Classify CWD containment with isSubpath instead of startsWith('..'), which misclassified in-tree directories named like '..cfg'. - Document the deliberate exclusions: baseline rules are not announced (see ruleCount), and the Windows cross-drive arm is consciously untested on POSIX CI. --- packages/cli/src/ui/AppContainer.test.tsx | 33 +++----- packages/cli/src/ui/AppContainer.tsx | 37 +++++---- .../cli/src/ui/utils/commandUtils.test.ts | 78 +++++++++++++++++++ packages/cli/src/ui/utils/commandUtils.ts | 39 ++++++++++ .../core/src/utils/memoryDiscovery.test.ts | 12 +++ packages/core/src/utils/memoryDiscovery.ts | 10 ++- 6 files changed, 170 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 05f1c43b758..03631d8eec4 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6252,13 +6252,22 @@ describe('AppContainer State Management', () => { expect(announcementCalls(addItem)).toHaveLength(1); }); - it('emits nothing when no context files are loaded', () => { + it('emits nothing when no context files are loaded, and re-arms for files attached later', () => { const { addItem } = renderAnnouncementHarness([]); capturedUIActions.handleFinalSubmit('hello', { submittedPrompt: 'hello', }); expect(announcementCalls(addItem)).toHaveLength(0); + + // Files attached later in the session (e.g. /directory add) must still + // get their one-shot notice: the latch is only consumed when something + // was actually announced. + vi.mocked(mockConfig.getContextFilePaths).mockReturnValue(['QWEN.md']); + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + expect(announcementCalls(addItem)).toHaveLength(1); }); it('does not consume the latch on a whitespace-only prompt', () => { @@ -6307,28 +6316,6 @@ describe('AppContainer State Management', () => { }); expect(announcementCalls(addItem)).toHaveLength(1); }); - - it('does not consume the latch while shell mode is active', () => { - const { addItem } = renderAnnouncementHarness(['QWEN.md']); - - // Shell-mode submissions are intercepted by the shell processor and - // never reach the model. - act(() => { - capturedUIActions.setShellModeActive(true); - }); - capturedUIActions.handleFinalSubmit('ls -la', { - submittedPrompt: 'ls -la', - }); - expect(announcementCalls(addItem)).toHaveLength(0); - - act(() => { - capturedUIActions.setShellModeActive(false); - }); - capturedUIActions.handleFinalSubmit('hello', { - submittedPrompt: 'hello', - }); - expect(announcementCalls(addItem)).toHaveLength(1); - }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index b3844eb7814..036e8cf0144 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -166,7 +166,11 @@ import { } from './hooks/useGeminiStream.js'; import type { TrackedExecutingToolCall } from './hooks/useReactToolScheduler.js'; import { useVim } from './hooks/vim.js'; -import { isBtwCommand, isSlashCommand } from './utils/commandUtils.js'; +import { + consumesContextAnnouncementLatch, + isBtwCommand, + isSlashCommand, +} from './utils/commandUtils.js'; import { detectWorkflowKeyword, buildWorkflowSteeringNotice, @@ -835,6 +839,12 @@ export const AppContainer = (props: AppContainerProps) => { // users can verify discovery (e.g., catch typos in context.fileName) // without digging into debug logs (#5267). const contextFilesAnnouncedRef = useRef(false); + // /clear and other same-process session switches wipe the emitted INFO + // item without remounting this component while context files stay + // attached, so re-arm the latch for the new session's first prompt. + useEffect(() => { + contextFilesAnnouncedRef.current = false; + }, [sessionStats.sessionId]); const activeWorktree = useMemo( () => worktreeSession @@ -2473,24 +2483,25 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } - // Mirror the downstream input classification (trim + blank + btw + - // shell-mode exclusions) so the latch is only consumed by submissions - // that actually reach the model. Model-invocable slash commands - // (skills, MCP prompts) are expanded into a submit_prompt and sent to - // the model, so they consume the latch like plain prompts. + // Mirror the downstream input classification so the latch is only + // consumed by submissions that actually reach the model (see + // consumesContextAnnouncementLatch). Queued (deferUntilIdle) + // submissions are admitted later, so they don't consume it here. const trimmedPrompt = userPromptText.trim(); if ( - trimmedPrompt.length > 0 && + !options?.deferUntilIdle && !contextFilesAnnouncedRef.current && - (!isSlashCommand(trimmedPrompt) || - parseSlashCommand(trimmedPrompt, slashCommands).commandToExecute - ?.modelInvocable === true) && - !isBtwCommand(trimmedPrompt) && - !shellModeActive + consumesContextAnnouncementLatch(trimmedPrompt, { + shellModeActive, + slashCommands, + }) ) { - contextFilesAnnouncedRef.current = true; const contextFilePaths = config.getContextFilePaths(); if (contextFilePaths.length > 0) { + // Consume the latch only when something was actually announced, + // so files attached later in the session (e.g. /directory add) + // still get their one-shot notice. + contextFilesAnnouncedRef.current = true; historyManager.addItem( { type: MessageType.INFO, diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 8e1d8502521..860099ac90f 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,11 +14,13 @@ import { copyToClipboard, getUrlOpenCommand, CodePage, + consumesContextAnnouncementLatch, findMidInputSlashCommand, findSlashCommandTokens, getBestSlashCommandMatch, } from './commandUtils.js'; import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; +import { CommandKind, type SlashCommand } from '../commands/types.js'; // Mock child_process vi.mock('child_process'); @@ -1259,3 +1261,79 @@ describe('getBestSlashCommandMatch', () => { expect(result!.suffix).toBe(''); }); }); + +// --------------------------------------------------------------------------- +// consumesContextAnnouncementLatch +// --------------------------------------------------------------------------- +describe('consumesContextAnnouncementLatch', () => { + const makeCommand = (name: string, modelInvocable: boolean): SlashCommand => + ({ + name, + description: `${name} desc`, + kind: modelInvocable ? CommandKind.SKILL : CommandKind.BUILT_IN, + modelInvocable, + action: vi.fn(), + }) as SlashCommand; + + const slashCommands = [ + makeCommand('feat-dev', true), + makeCommand('help', false), + ]; + const options = (shellModeActive: boolean) => ({ + shellModeActive, + slashCommands, + }); + + it('admits a plain prompt', () => { + expect(consumesContextAnnouncementLatch('hello', options(false))).toBe( + true, + ); + }); + + it('rejects blank input (dropped by the queue)', () => { + expect(consumesContextAnnouncementLatch('', options(false))).toBe(false); + }); + + it('rejects btw side-questions (they bypass the model)', () => { + expect( + consumesContextAnnouncementLatch('?btw side note', options(false)), + ).toBe(false); + }); + + it('rejects local slash commands (no model turn)', () => { + expect(consumesContextAnnouncementLatch('/help', options(false))).toBe( + false, + ); + }); + + it('rejects unknown slash commands', () => { + expect( + consumesContextAnnouncementLatch('/no-such-command x', options(false)), + ).toBe(false); + }); + + it('admits model-invocable slash commands (expanded to submit_prompt)', () => { + expect( + consumesContextAnnouncementLatch('/feat-dev implement X', options(false)), + ).toBe(true); + }); + + it('rejects plain input while shell mode is active', () => { + expect(consumesContextAnnouncementLatch('ls -la', options(true))).toBe( + false, + ); + }); + + it('admits model-invocable slash commands even while shell mode is active', () => { + // Slash commands are routed before the shell-mode intercept. + expect( + consumesContextAnnouncementLatch('/feat-dev implement X', options(true)), + ).toBe(true); + }); + + it('rejects local slash commands while shell mode is active', () => { + expect(consumesContextAnnouncementLatch('/help', options(true))).toBe( + false, + ); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index c49a56f9c43..18b2f83ed8e 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -10,6 +10,7 @@ import { createDebugLogger } from '@qwen-code/qwen-code-core'; import { isStackedSkillCompletableCommand, isValidStackedSkillPrefix, + parseSlashCommand, } from '../../utils/commands.js'; import type { SlashCommand } from '../commands/types.js'; import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; @@ -98,6 +99,44 @@ export const isBtwCommand = (query: string): boolean => { return trimmed.length > 0 && BTW_COMMAND_RE.test(trimmed); }; +/** + * Whether a submission consumes the one-shot context-file announcement. + * Mirrors the downstream input classification so only submissions that + * actually reach the model consume it: blank input is dropped by the queue, + * btw side-questions and shell-mode input bypass the model, local slash + * commands resolve without a model turn — but model-invocable slash + * commands (skills, MCP prompts) are expanded into a submit_prompt that is + * sent to the model, and slash commands are routed before the shell-mode + * intercept, so both consume it even while shell mode is active. + */ +export function consumesContextAnnouncementLatch( + trimmedPrompt: string, + options: { + shellModeActive: boolean; + slashCommands: readonly SlashCommand[]; + }, +): boolean { + if (trimmedPrompt.length === 0) { + return false; + } + if (isBtwCommand(trimmedPrompt)) { + return false; + } + if (isSlashCommand(trimmedPrompt)) { + // Slash commands are routed before the shell-mode intercept, so shell + // mode does not exclude them; only the model-invocable ones (expanded + // into a submit_prompt) reach the model. + return ( + parseSlashCommand(trimmedPrompt, options.slashCommands).commandToExecute + ?.modelInvocable === true + ); + } + if (options.shellModeActive) { + return false; + } + return true; +} + const debugLogger = createDebugLogger('COMMAND_UTILS'); const formatCommandFailure = (error: unknown, command: string): string => diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index ee0359ea6c2..e0d3f7f05ec 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -1393,6 +1393,18 @@ describe('formatContextFileDisplayPath', () => { ).toBe('QWEN.md'); }); + it('keeps CWD-relative paths for directories with leading-dot names', () => { + // '..cfg' merely starts with two dots; it is not a real '..' segment, so + // the file is inside the CWD tree and must not be tildeified. + const projUnderHome = path.join(home, 'proj2'); + expect( + formatContextFileDisplayPath( + path.join(projUnderHome, '..cfg', 'QWEN.md'), + projUnderHome, + ), + ).toBe(path.join('..cfg', 'QWEN.md')); + }); + it('does not tildeify sibling directories sharing the home prefix', () => { const file = path.join(siblingHome, 'proj', 'QWEN.md'); expect(formatContextFileDisplayPath(file, proj)).toBe( diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 3987cb2633d..5c92e36aec0 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -331,9 +331,11 @@ export function formatContextFileDisplayPath( return stripAnsiAndControl(filePath); } const relativePath = path.relative(currentWorkingDirectory, filePath); - // On Windows, cross-drive targets come back as absolute paths (no common - // root) instead of `..` chains; treat them as outside the CWD tree too. - if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + // isSubpath rejects real `..` segments (not mere `..`-prefixed names like + // `..cfg`) and absolute relatives, which is what Windows cross-drive + // targets produce. That arm is consciously untested: POSIX `path.relative` + // never returns an absolute path and the fixtures share one volume. + if (!isSubpath(currentWorkingDirectory, filePath)) { const tildeified = tildeifyPath(filePath, userHomePath); if (tildeified !== filePath) { return stripAnsiAndControl(tildeified); @@ -374,6 +376,8 @@ export interface LoadServerHierarchicalMemoryResponse { * inside the CWD tree, `~/...` shortcuts for files under the user home. * Display-only — do not resolve them against the CWD. * Lets callers tell users which files were actually attached (see #5267). + * Baseline rules (`.qwen/rules/`) are injected separately and deliberately + * not listed here (see `ruleCount`). */ contextFilePaths: string[]; /** Number of baseline rules injected at session start. */ From 643f1e7e7cda0a1c733f2561c401770e03eaf980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 11 Aug 2026 19:42:45 +0800 Subject: [PATCH 06/17] fix(cli): address fifth-round review feedback on context file visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert the deferUntilIdle exclusion (R4-8): it silently broke the announcement for queue-only submissions, which never pass back through handleFinalSubmit once the drain admits them. The latch is now consumed at queue time; emitting at the drain admission choke point is a deeper refactor deferred for this feature. - Re-arm the latch on Ctrl-L clear-screen, which wipes the emitted INFO item without a session switch (completes the screen-clear case R4-13 named). - Soften the predicate docstring and the guard comment to be honest about the heuristic: btw is deliberately exempt (a side question that doesn't advance the main conversation, even though it may fork a model call), not "bypassing the model"; consumption is a prediction, not an admission guarantee. - Correct the ANSI-stripping test comment to the real mechanism (stripVTControlCharacters matches the ESC[2Jb…BEL run as one BEL-terminated sequence), so a future maintainer doesn't misdiagnose. --- packages/cli/src/ui/AppContainer.tsx | 15 ++++++++++----- packages/cli/src/ui/utils/commandUtils.test.ts | 2 +- packages/cli/src/ui/utils/commandUtils.ts | 18 +++++++++++------- .../core/src/utils/memoryDiscovery.test.ts | 5 +++-- 4 files changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 036e8cf0144..369815194be 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2483,13 +2483,15 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } - // Mirror the downstream input classification so the latch is only - // consumed by submissions that actually reach the model (see - // consumesContextAnnouncementLatch). Queued (deferUntilIdle) - // submissions are admitted later, so they don't consume it here. + // Heuristically mirror the downstream input classification (see + // consumesContextAnnouncementLatch) so the latch is consumed by the + // submission most likely to start the first main model turn. This is a + // prediction, not an admission guarantee: rare post-admission aborts + // (ESC, expansion errors) and built-in submit_prompt commands without + // the modelInvocable flag are not re-armed here; consuming at the true + // admission choke point is a deeper refactor deferred for this feature. const trimmedPrompt = userPromptText.trim(); if ( - !options?.deferUntilIdle && !contextFilesAnnouncedRef.current && consumesContextAnnouncementLatch(trimmedPrompt, { shellModeActive, @@ -2993,6 +2995,9 @@ export const AppContainer = (props: AppContainerProps) => { const handleClearScreen = useCallback(() => { clearPendingStateRef.current(); + // Ctrl-L wipes the emitted INFO item without a session switch, so re-arm + // the latch or the remaining attached files go unannounced afterwards. + contextFilesAnnouncedRef.current = false; historyManager.clearItems(); clearScreen(); remountStaticHistory(); diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 860099ac90f..c469789fb7d 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -1294,7 +1294,7 @@ describe('consumesContextAnnouncementLatch', () => { expect(consumesContextAnnouncementLatch('', options(false))).toBe(false); }); - it('rejects btw side-questions (they bypass the model)', () => { + it('rejects btw side-questions (deliberately exempt, not a main turn)', () => { expect( consumesContextAnnouncementLatch('?btw side note', options(false)), ).toBe(false); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 18b2f83ed8e..f7dedeab7d2 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -101,13 +101,17 @@ export const isBtwCommand = (query: string): boolean => { /** * Whether a submission consumes the one-shot context-file announcement. - * Mirrors the downstream input classification so only submissions that - * actually reach the model consume it: blank input is dropped by the queue, - * btw side-questions and shell-mode input bypass the model, local slash - * commands resolve without a model turn — but model-invocable slash - * commands (skills, MCP prompts) are expanded into a submit_prompt that is - * sent to the model, and slash commands are routed before the shell-mode - * intercept, so both consume it even while shell mode is active. + * Heuristically mirrors the downstream input classification so the latch + * is consumed by the submission most likely to start the first main model + * turn: blank input is dropped by the queue, btw side-questions are + * deliberately exempt (they don't advance the main conversation even + * though they may fork a model call), shell-mode input is intercepted, and + * local slash commands resolve without a model turn — but model-invocable + * slash commands (skills, MCP prompts) are expanded into a submit_prompt + * and routed before the shell-mode intercept, so they consume it even + * while shell mode is active. This is a prediction, not an admission + * guarantee; rare post-admission aborts and built-in submit_prompt + * commands without the modelInvocable flag are out of scope here. */ export function consumesContextAnnouncementLatch( trimmedPrompt: string, diff --git a/packages/core/src/utils/memoryDiscovery.test.ts b/packages/core/src/utils/memoryDiscovery.test.ts index e0d3f7f05ec..2a42eb6f871 100644 --- a/packages/core/src/utils/memoryDiscovery.test.ts +++ b/packages/core/src/utils/memoryDiscovery.test.ts @@ -1424,8 +1424,9 @@ describe('formatContextFileDisplayPath', () => { }); it('strips ANSI escapes and control characters from display paths', () => { - // CSI parameter bytes span 0x30-0x3F, so ESC[2J consumes the 'b' too; - // BEL is removed by the residual control-char pass. + // stripVTControlCharacters matches ESC[2Jb…\x07 as one BEL-terminated + // sequence, swallowing 'b' with the BEL; a bare BEL would survive it, + // which is why this fixture pairs the two to exercise that pass. expect( formatContextFileDisplayPath( path.join(proj, 'a\u001b[2Jb\u0007.md'), From 0e9c0526ce0fe7dfd13de79e28cee173297852d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Wed, 12 Aug 2026 15:37:15 +0800 Subject: [PATCH 07/17] fix(cli): re-arm context-file announcement latch on conversation rewind - Rewind (/rewind / double-Esc) filters history to before the target turn via loadHistory, wiping the emitted INFO item without a session switch. Add a conditional re-arm in handleRewindConfirm: the latch re-arms iff the rewound history no longer contains the announcement, so rewinding past it re-announces on the next prompt while rewinding to a later turn doesn't duplicate it. Completes the wipe-path coverage alongside /clear (session id effect) and Ctrl-L (handleClearScreen). --- packages/cli/src/ui/AppContainer.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 369815194be..0996dd59f48 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3648,6 +3648,18 @@ export const AppContainer = (props: AppContainerProps) => { clearPendingStateRef.current(); historyManager.loadHistory(truncatedUi); + // Re-arm the latch iff the rewound history no longer contains the + // announcement. Rewinding to a turn before files were attached + // filters the INFO out while the files stay in the system prompt, + // so the next prompt must re-announce; rewinding to a turn at/after + // the announcement keeps the INFO and must not duplicate it. + contextFilesAnnouncedRef.current = !truncatedUi.some( + (item) => + item.type === MessageType.INFO && + typeof item.text === 'string' && + item.text.startsWith('Read context files:'), + ); + refreshStatic(); if (userItem.type === 'user' && userItem.text) { From a77a3ae6481a8ab9f5425d8823aa3de100e2bb5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Wed, 12 Aug 2026 19:08:01 +0800 Subject: [PATCH 08/17] fix(cli): correct rewind latch polarity and cover Ctrl-L re-arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R6-1 (Critical): the rewind latch re-arm shipped inverted. Latch semantics are true=consumed / false=armed; the correct assignment is truncatedUi.some(announcement) — true (stay consumed) when the INFO survives the rewind, false (re-arm) when it was filtered out. The negation inverted both branches: rewinding past the announcement left the latch consumed (no re-announce), rewinding to a later turn armed it (duplicate). Drop the negation. - R6-2: add 're-arms the latch after Ctrl-L wipes the INFO' — submit, handleClearScreen, submit again, assert two announcements. Locks the R5-8 Ctrl-L re-arm; removing that line now fails the suite. --- packages/cli/src/ui/AppContainer.test.tsx | 19 +++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 03631d8eec4..2fc137fb0e6 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6270,6 +6270,25 @@ describe('AppContainer State Management', () => { expect(announcementCalls(addItem)).toHaveLength(1); }); + it('re-arms the latch after Ctrl-L (handleClearScreen) wipes the INFO', () => { + const { addItem } = renderAnnouncementHarness(['QWEN.md']); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + + // Ctrl-L wipes the emitted INFO without a session switch; the latch + // must re-arm so the still-attached files re-announce on the next + // prompt. + capturedUIActions.handleClearScreen(); + + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + expect(announcementCalls(addItem)).toHaveLength(2); + }); + it('does not consume the latch on a whitespace-only prompt', () => { const { addItem } = renderAnnouncementHarness(['QWEN.md']); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 0996dd59f48..3502963cfec 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3653,7 +3653,7 @@ export const AppContainer = (props: AppContainerProps) => { // filters the INFO out while the files stay in the system prompt, // so the next prompt must re-announce; rewinding to a turn at/after // the announcement keeps the INFO and must not duplicate it. - contextFilesAnnouncedRef.current = !truncatedUi.some( + contextFilesAnnouncedRef.current = truncatedUi.some( (item) => item.type === MessageType.INFO && typeof item.text === 'string' && From a0549dc0cf0180a28b380c5270bbcdce3a573c6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 13 Aug 2026 11:17:07 +0800 Subject: [PATCH 09/17] fix(cli): address R8 review on context-file announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppContainer.tsx: performMemoryRefresh anchors on getWorkingDir() not process.cwd(), matching the /context read site (R1) and ACP/daemon under skipProcessChdir (R8-5). - commandUtils.ts: docstring drops the false "MCP prompts" claim (McpPromptLoader has no modelInvocable field) and the inline comment names the disableModelInvocation / description-less exemptions instead of a false exclusivity (R8-4, R8-7). - AppContainer.test.tsx: renderRewindHarness takes optional history / contextFilePaths and gains two rewind tests — past the announcement re-arms (re-announces), retaining it stays consumed (no dup); each fails under its !some/some mutation, covering the R6-1 inversion (R8-1). - directoryCommand.test.tsx: strengthen the loadServerHierarchicalMemory assertion to pin the getWorkingDir() anchor (R8-6). R8-2 (defensive reset) and R8-9 (/cd plumbing) declined: the reset is on a path that can't hold stale values (safe-mode early return); /cd needs cross-file plumbing, same class as the deferred /restore — both are subsumed by a self-healing latch follow-up. --- packages/cli/src/ui/AppContainer.test.tsx | 76 ++++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 2 +- .../src/ui/commands/directoryCommand.test.tsx | 12 ++- packages/cli/src/ui/utils/commandUtils.ts | 8 +- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 2fc137fb0e6..17621c8d06f 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -527,10 +527,12 @@ describe('AppContainer State Management', () => { }; fileRewindError?: Error; noGeminiClient?: boolean; + history?: HistoryItem[]; + contextFilePaths?: string[]; }; const renderRewindHarness = (options: RewindHarnessOptions = {}) => { - const history: HistoryItem[] = [ + const history: HistoryItem[] = options.history ?? [ rewindUserItem(1, 'first prompt', 'prompt-1'), { id: 2, type: 'gemini', text: 'first response' }, rewindUserItem(3, 'second prompt', 'prompt-2'), @@ -612,6 +614,12 @@ describe('AppContainer State Management', () => { rewindRecording, } as unknown as NonNullable>); + if (options.contextFilePaths) { + vi.spyOn(mockConfig, 'getContextFilePaths').mockReturnValue( + options.contextFilePaths, + ); + } + render( { ); }); + it('re-arms the latch when rewinding past the context-file announcement', async () => { + // Announcement sits after the rewind target, so it is filtered out of + // truncatedUi; the latch re-arms and the next prompt re-announces the + // still-attached files. + const history: HistoryItem[] = [ + rewindUserItem(1, 'first prompt', 'prompt-1'), + { id: 2, type: 'gemini', text: 'first response' }, + rewindUserItem(3, 'second prompt', 'prompt-2'), + { + id: 4, + type: MessageType.INFO, + text: 'Read context files: QWEN.md', + }, + ]; + const harness = renderRewindHarness({ + history, + contextFilePaths: ['QWEN.md'], + }); + + await runRewind(harness.target, 'both'); + + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + const announcements = harness.addItem.mock.calls.filter( + ([item]) => + item.type === MessageType.INFO && + typeof item.text === 'string' && + item.text.startsWith('Read context files:'), + ); + expect(announcements).toHaveLength(1); + }); + + it('keeps the latch consumed when rewinding to a turn after the announcement', async () => { + // Announcement sits before the rewind target, so it survives in + // truncatedUi; the latch stays consumed and the next prompt does not + // duplicate the announcement. + const history: HistoryItem[] = [ + rewindUserItem(1, 'first prompt', 'prompt-1'), + { + id: 2, + type: MessageType.INFO, + text: 'Read context files: QWEN.md', + }, + rewindUserItem(3, 'second prompt', 'prompt-2'), + { id: 4, type: 'gemini', text: 'second response' }, + ]; + const harness = renderRewindHarness({ + history, + contextFilePaths: ['QWEN.md'], + }); + + await runRewind(harness.target, 'both'); + + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + const announcements = harness.addItem.mock.calls.filter( + ([item]) => + item.type === MessageType.INFO && + typeof item.text === 'string' && + item.text.startsWith('Read context files:'), + ); + expect(announcements).toHaveLength(0); + }); + it('restores code only without truncating conversation history', async () => { const harness = renderRewindHarness(); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3502963cfec..b6eaebec205 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1984,7 +1984,7 @@ export const AppContainer = (props: AppContainerProps) => { conditionalRules, projectRoot, } = await loadHierarchicalGeminiMemory( - process.cwd(), + config.getWorkingDir(), settings.merged.context?.loadFromIncludeDirectories ? config.getWorkspaceContext().getDirectories() : [], diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 81e34428831..82dd4de905e 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -271,7 +271,17 @@ describe('directoryCommand', () => { path.normalize('/home/user/new-project'), ); - expect(loadServerHierarchicalMemory).toHaveBeenCalled(); + // Pin the CWD anchor (getWorkingDir, not process.cwd) and the new + // directory so an anchor regression can't slip through green. + expect(loadServerHierarchicalMemory).toHaveBeenCalledWith( + '/test/dir', + expect.arrayContaining([path.normalize('/home/user/new-project')]), + expect.anything(), + expect.anything(), + expect.anything(), + 'tree', + expect.anything(), + ); expect(mockConfig.setUserMemory).toHaveBeenCalledWith('reloaded memory'); expect(mockConfig.setContextFilePaths).toHaveBeenCalledWith([ 'a/QWEN.md', diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index f7dedeab7d2..a17859a96bc 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -107,8 +107,8 @@ export const isBtwCommand = (query: string): boolean => { * deliberately exempt (they don't advance the main conversation even * though they may fork a model call), shell-mode input is intercepted, and * local slash commands resolve without a model turn — but model-invocable - * slash commands (skills, MCP prompts) are expanded into a submit_prompt - * and routed before the shell-mode intercept, so they consume it even + * slash commands (skills) are expanded into a submit_prompt and routed + * before the shell-mode intercept, so they consume it even * while shell mode is active. This is a prediction, not an admission * guarantee; rare post-admission aborts and built-in submit_prompt * commands without the modelInvocable flag are out of scope here. @@ -129,7 +129,9 @@ export function consumesContextAnnouncementLatch( if (isSlashCommand(trimmedPrompt)) { // Slash commands are routed before the shell-mode intercept, so shell // mode does not exclude them; only the model-invocable ones (expanded - // into a submit_prompt) reach the model. + // into a submit_prompt) reach the model — user-invoked skills with + // disableModelInvocation and description-less extension commands also + // expand to submit_prompt but are deliberately exempt. return ( parseSlashCommand(trimmedPrompt, options.slashCommands).commandToExecute ?.modelInvocable === true From fdc2c8e55904cc47c345a3eb8156c2212a035f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 13 Aug 2026 14:35:55 +0800 Subject: [PATCH 10/17] fix(cli): address R9 review on context-file announcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commandUtils.ts: narrow the btw exemption to /btw only. ?btw is not a slash command and goes to the main model as a plain query, so it must consume the latch. Updated 2 tests that pinned the old ?btw exemption (R9-2). - AppContainer.tsx: document the /cd latch gap in the consume-point comment — relocateWorkingDirectory swaps the file set without re-arming, deferred to the self-healing latch follow-up (R8-9). - AppContainer.test.tsx: add performMemoryRefresh anchor test that mocks loadHierarchicalGeminiMemory, captures the callback from useGeminiStream mock args, and asserts the first arg is config.getWorkingDir() (not process.cwd()) and setContextFilePaths received the loader's paths (R9-1). --- packages/cli/src/ui/AppContainer.test.tsx | 64 ++++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 5 ++ .../cli/src/ui/utils/commandUtils.test.ts | 10 ++- packages/cli/src/ui/utils/commandUtils.ts | 13 ++-- 4 files changed, 84 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 72abbfb4169..8772ede3cea 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -183,6 +183,15 @@ vi.mock('../utils/events.js'); vi.mock('../utils/handleAutoUpdate.js'); vi.mock('../utils/cleanup.js'); +const mockLoadHierarchicalGeminiMemory = vi.hoisted(() => vi.fn()); +vi.mock('../config/config.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadHierarchicalGeminiMemory: mockLoadHierarchicalGeminiMemory, + }; +}); + import { useHistory } from './hooks/useHistoryManager.js'; import { useThemeCommand } from './hooks/useThemeCommand.js'; import { useAuthCommand } from './auth/useAuth.js'; @@ -6388,8 +6397,8 @@ describe('AppContainer State Management', () => { it('does not consume the latch on a leading /btw command', () => { const { addItem } = renderAnnouncementHarness(['QWEN.md']); - capturedUIActions.handleFinalSubmit('?btw side note', { - submittedPrompt: '?btw side note', + capturedUIActions.handleFinalSubmit('/btw side note', { + submittedPrompt: '/btw side note', }); expect(announcementCalls(addItem)).toHaveLength(0); @@ -6482,6 +6491,57 @@ describe('AppContainer State Management', () => { }); expect(announcementCalls(addItem)).toHaveLength(1); }); + + it('performMemoryRefresh anchors on config.getWorkingDir() and updates contextFilePaths', async () => { + mockLoadHierarchicalGeminiMemory.mockResolvedValue({ + memoryContent: 'content', + fileCount: 1, + contextFilePaths: ['/custom/QWEN.md'], + conditionalRules: [], + projectRoot: '/custom', + }); + vi.spyOn(mockConfig, 'getWorkingDir').mockReturnValue( + '/custom/workspace', + ); + vi.spyOn(mockConfig, 'isSafeMode').mockReturnValue(false); + const setContextFilePathsSpy = vi.spyOn( + mockConfig, + 'setContextFilePaths', + ); + + render( + , + ); + + // performMemoryRefresh is the 12th arg (index 11) passed to + // useGeminiStream by AppContainer. + const calls = mockedUseGeminiStream.mock.calls; + const performMemoryRefresh = calls[ + calls.length - 1 + ]![11] as () => Promise; + expect(typeof performMemoryRefresh).toBe('function'); + + await act(async () => { + await performMemoryRefresh(); + }); + + expect(mockLoadHierarchicalGeminiMemory).toHaveBeenCalledWith( + '/custom/workspace', + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + expect.anything(), + ); + expect(setContextFilePathsSpy).toHaveBeenCalledWith(['/custom/QWEN.md']); + }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ed700584006..f7ad5cd9dab 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2566,6 +2566,11 @@ export const AppContainer = (props: AppContainerProps) => { // (ESC, expansion errors) and built-in submit_prompt commands without // the modelInvocable flag are not re-armed here; consuming at the true // admission choke point is a deeper refactor deferred for this feature. + // Known gap: /cd (relocateWorkingDirectory) swaps the attached + // context-file set within the same session but does not re-arm the + // latch, so the new file set is never announced. A self-healing latch + // that watches the context-file set would cover /cd, /restore, and + // rewind centrally; deferred as a follow-up. const trimmedPrompt = userPromptText.trim(); if ( !contextFilesAnnouncedRef.current && diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index c469789fb7d..7b9fd57028b 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -1294,12 +1294,18 @@ describe('consumesContextAnnouncementLatch', () => { expect(consumesContextAnnouncementLatch('', options(false))).toBe(false); }); - it('rejects btw side-questions (deliberately exempt, not a main turn)', () => { + it('rejects /btw side-questions (fork via runForkedAgent, no main turn)', () => { expect( - consumesContextAnnouncementLatch('?btw side note', options(false)), + consumesContextAnnouncementLatch('/btw side note', options(false)), ).toBe(false); }); + it('consumes ?btw (not a slash command, goes to the main model)', () => { + expect( + consumesContextAnnouncementLatch('?btw side note', options(false)), + ).toBe(true); + }); + it('rejects local slash commands (no model turn)', () => { expect(consumesContextAnnouncementLatch('/help', options(false))).toBe( false, diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index a17859a96bc..51a03b081bc 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -103,9 +103,11 @@ export const isBtwCommand = (query: string): boolean => { * Whether a submission consumes the one-shot context-file announcement. * Heuristically mirrors the downstream input classification so the latch * is consumed by the submission most likely to start the first main model - * turn: blank input is dropped by the queue, btw side-questions are - * deliberately exempt (they don't advance the main conversation even - * though they may fork a model call), shell-mode input is intercepted, and + * turn: blank input is dropped by the queue, /btw side-questions are + * deliberately exempt (they fork via runForkedAgent without advancing the + * main conversation — note ?btw is NOT exempt: it is not a slash command + * and goes to the main model as a plain query), shell-mode input is + * intercepted, and * local slash commands resolve without a model turn — but model-invocable * slash commands (skills) are expanded into a submit_prompt and routed * before the shell-mode intercept, so they consume it even @@ -123,7 +125,10 @@ export function consumesContextAnnouncementLatch( if (trimmedPrompt.length === 0) { return false; } - if (isBtwCommand(trimmedPrompt)) { + // Only /btw forks (runForkedAgent, no main turn); ?btw is not a slash + // command and reaches the main model as a plain query, so it must + // consume the latch like any other prompt. + if (/^\/btw(?:\s|$)/.test(trimmedPrompt)) { return false; } if (isSlashCommand(trimmedPrompt)) { From 9ce93cd48a10adfbccbc58064bb12594e1f3ba33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 13 Aug 2026 17:22:24 +0800 Subject: [PATCH 11/17] =?UTF-8?q?fix(cli):=20address=20R10=20review=20?= =?UTF-8?q?=E2=80=94=20shared=20constant,=20project-local=20marker=20test,?= =?UTF-8?q?=20latch-consuming=20rewind=20test,=20gap=20comment=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract CONTEXT_FILES_ANNOUNCEMENT_PREFIX constant + isContextFilesAnnouncement predicate in commandUtils.ts; emission site, rewind matcher, and all test fixtures/helpers now reference them, eliminating exact-spelling coupling - Add project-local marker test (QWEN.md, docs/QWEN.md) with getWorkingDir != process.cwd() to kill anchor-divergence mutation - Rewind re-arm test now submits before rewinding to consume the latch, asserting 2 total announcements so the deletion mutation is killed - Correct consume-point comment and add /directory add + performMemoryRefresh to the known-gap list alongside /cd --- packages/cli/src/ui/AppContainer.test.tsx | 45 ++++++++++--------- packages/cli/src/ui/AppContainer.tsx | 27 +++++------ .../src/ui/commands/contextCommand.test.ts | 26 +++++++++++ packages/cli/src/ui/utils/commandUtils.ts | 18 ++++++++ 4 files changed, 82 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 8772ede3cea..d0ac02bdad3 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -95,6 +95,10 @@ import { ToolCallStatus, } from './types.js'; import { CommandKind } from './commands/types.js'; +import { + CONTEXT_FILES_ANNOUNCEMENT_PREFIX, + isContextFilesAnnouncement, +} from './utils/commandUtils.js'; import { ICON } from './constants.js'; import type { RestoreOption } from './components/RewindSelector.js'; import { Box, measureElement } from 'ink'; @@ -5958,7 +5962,8 @@ describe('AppContainer State Management', () => { it('re-arms the latch when rewinding past the context-file announcement', async () => { // Announcement sits after the rewind target, so it is filtered out of // truncatedUi; the latch re-arms and the next prompt re-announces the - // still-attached files. + // still-attached files. We submit once before rewinding to consume + // the latch, so the re-arm transition is actually exercised. const history: HistoryItem[] = [ rewindUserItem(1, 'first prompt', 'prompt-1'), { id: 2, type: 'gemini', text: 'first response' }, @@ -5966,7 +5971,7 @@ describe('AppContainer State Management', () => { { id: 4, type: MessageType.INFO, - text: 'Read context files: QWEN.md', + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md`, }, ]; const harness = renderRewindHarness({ @@ -5974,18 +5979,24 @@ describe('AppContainer State Management', () => { contextFilePaths: ['QWEN.md'], }); + // Consume the latch so the rewind's re-arm is a real transition. + capturedUIActions.handleFinalSubmit('first', { + submittedPrompt: 'first', + }); + const announcementsBefore = harness.addItem.mock.calls.filter(([item]) => + isContextFilesAnnouncement(item), + ); + expect(announcementsBefore).toHaveLength(1); + await runRewind(harness.target, 'both'); capturedUIActions.handleFinalSubmit('again', { submittedPrompt: 'again', }); - const announcements = harness.addItem.mock.calls.filter( - ([item]) => - item.type === MessageType.INFO && - typeof item.text === 'string' && - item.text.startsWith('Read context files:'), + const announcementsAfter = harness.addItem.mock.calls.filter(([item]) => + isContextFilesAnnouncement(item), ); - expect(announcements).toHaveLength(1); + expect(announcementsAfter).toHaveLength(2); }); it('keeps the latch consumed when rewinding to a turn after the announcement', async () => { @@ -5997,7 +6008,7 @@ describe('AppContainer State Management', () => { { id: 2, type: MessageType.INFO, - text: 'Read context files: QWEN.md', + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md`, }, rewindUserItem(3, 'second prompt', 'prompt-2'), { id: 4, type: 'gemini', text: 'second response' }, @@ -6012,11 +6023,8 @@ describe('AppContainer State Management', () => { capturedUIActions.handleFinalSubmit('again', { submittedPrompt: 'again', }); - const announcements = harness.addItem.mock.calls.filter( - ([item]) => - item.type === MessageType.INFO && - typeof item.text === 'string' && - item.text.startsWith('Read context files:'), + const announcements = harness.addItem.mock.calls.filter(([item]) => + isContextFilesAnnouncement(item), ); expect(announcements).toHaveLength(0); }); @@ -6343,12 +6351,7 @@ describe('AppContainer State Management', () => { }; const announcementCalls = (addItem: ReturnType) => - addItem.mock.calls.filter( - ([item]) => - item.type === MessageType.INFO && - typeof item.text === 'string' && - item.text.startsWith('Read context files:'), - ); + addItem.mock.calls.filter(([item]) => isContextFilesAnnouncement(item)); it('announces loaded context files above the first real prompt, once', () => { const { addItem, enqueueMessage } = renderAnnouncementHarness([ @@ -6363,7 +6366,7 @@ describe('AppContainer State Management', () => { expect(addItem).toHaveBeenCalledWith( expect.objectContaining({ type: MessageType.INFO, - text: 'Read context files: QWEN.md, ~/.qwen/QWEN.md', + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md, ~/.qwen/QWEN.md`, }), expect.any(Number), ); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f7ad5cd9dab..7cc9b04643a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -169,8 +169,10 @@ import { import type { TrackedExecutingToolCall } from './hooks/useReactToolScheduler.js'; import { useVim } from './hooks/vim.js'; import { + CONTEXT_FILES_ANNOUNCEMENT_PREFIX, consumesContextAnnouncementLatch, isBtwCommand, + isContextFilesAnnouncement, isSlashCommand, } from './utils/commandUtils.js'; import { @@ -2566,11 +2568,12 @@ export const AppContainer = (props: AppContainerProps) => { // (ESC, expansion errors) and built-in submit_prompt commands without // the modelInvocable flag are not re-armed here; consuming at the true // admission choke point is a deeper refactor deferred for this feature. - // Known gap: /cd (relocateWorkingDirectory) swaps the attached - // context-file set within the same session but does not re-arm the - // latch, so the new file set is never announced. A self-healing latch - // that watches the context-file set would cover /cd, /restore, and - // rewind centrally; deferred as a follow-up. + // Known gap: /cd, /directory add, and performMemoryRefresh swap the + // attached context-file set within the same session but do not re-arm + // the latch, so the new file set is never announced after the first + // prompt consumes it. A self-healing latch that watches the + // context-file set would cover these centrally; deferred as a + // follow-up. const trimmedPrompt = userPromptText.trim(); if ( !contextFilesAnnouncedRef.current && @@ -2581,14 +2584,15 @@ export const AppContainer = (props: AppContainerProps) => { ) { const contextFilePaths = config.getContextFilePaths(); if (contextFilePaths.length > 0) { - // Consume the latch only when something was actually announced, - // so files attached later in the session (e.g. /directory add) - // still get their one-shot notice. + // Consume the latch only when something was actually announced; + // files attached before the first prompt still get their one-shot + // notice. Files attached after (e.g. /directory add, + // performMemoryRefresh) are a known gap — see above. contextFilesAnnouncedRef.current = true; historyManager.addItem( { type: MessageType.INFO, - text: `Read context files: ${contextFilePaths.join(', ')}`, + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} ${contextFilePaths.join(', ')}`, }, Date.now(), ); @@ -3735,10 +3739,7 @@ export const AppContainer = (props: AppContainerProps) => { // so the next prompt must re-announce; rewinding to a turn at/after // the announcement keeps the INFO and must not duplicate it. contextFilesAnnouncedRef.current = truncatedUi.some( - (item) => - item.type === MessageType.INFO && - typeof item.text === 'string' && - item.text.startsWith('Read context files:'), + isContextFilesAnnouncement, ); refreshStatic(); diff --git a/packages/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index 79d5418c3d0..c2a9f06573e 100644 --- a/packages/cli/src/ui/commands/contextCommand.test.ts +++ b/packages/cli/src/ui/commands/contextCommand.test.ts @@ -311,6 +311,32 @@ describe('collectContextData (contextCommand)', () => { expect(data.memoryFiles).toHaveLength(1); expect(data.memoryFiles[0].path).toBe(path.join('~', '.qwen', 'QWEN.md')); }); + + it('renders project-local markers as relative paths when workingDir != cwd', async () => { + // The resolve+format round-trip must anchor on the session working dir, + // not process.cwd(); a mutation that passes process.cwd() as the display + // anchor renders every project-local file as a ../.. chain. + const workingDir = path.join(os.tmpdir(), 'context-session-dir'); + const memory = + `--- Context from: QWEN.md ---\n` + + `project rules\n` + + `--- End of Context from: QWEN.md ---\n` + + `--- Context from: docs/QWEN.md ---\n` + + `docs rules\n` + + `--- End of Context from: docs/QWEN.md ---`; + const config = { + ...makeMockConfig(), + getUserMemory: vi.fn().mockReturnValue(memory), + getAutoMemoryPrompt: vi.fn().mockReturnValue(''), + getWorkingDir: vi.fn().mockReturnValue(workingDir), + } as unknown as Config; + + const data = await collectContextData(config, true); + + expect(data.memoryFiles).toHaveLength(2); + expect(data.memoryFiles[0].path).toBe('QWEN.md'); + expect(data.memoryFiles[1].path).toBe(path.join('docs', 'QWEN.md')); + }); }); describe('/context shows three-tier thresholds', () => { diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 51a03b081bc..32b75bfe674 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -14,9 +14,27 @@ import { } from '../../utils/commands.js'; import type { SlashCommand } from '../commands/types.js'; import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; +import { MessageType } from '../types.js'; import { isWaylandSession, writeOsc52 } from './clipboardUtils.js'; import { toCodePoints } from './textUtils.js'; +/** Shared prefix for the context-files announcement INFO item. + * Used by both the emission site and the rewind re-arm matcher so the + * pairing is enforced by construction, not by exact-spelling coupling. */ +export const CONTEXT_FILES_ANNOUNCEMENT_PREFIX = 'Read context files:'; + +/** Whether a history item is the context-files announcement. */ +export function isContextFilesAnnouncement(item: { + type: MessageType; + text?: string; +}): boolean { + return ( + item.type === MessageType.INFO && + typeof item.text === 'string' && + item.text.startsWith(CONTEXT_FILES_ANNOUNCEMENT_PREFIX) + ); +} + /** * Common Windows console code pages (CP) used for encoding conversions. * From 8fbe5fea7468e3e9e7508ecb189c308410664e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 13 Aug 2026 17:32:06 +0800 Subject: [PATCH 12/17] fix(cli): widen isContextFilesAnnouncement param type to string for tsc --build HistoryItem union includes variants whose type is not in the MessageType enum (e.g. 'about', 'stats'), so { type: MessageType } is structurally incompatible. Widen to { type: string } so the predicate accepts all HistoryItem variants; the runtime check (=== MessageType.INFO) is unchanged. --- packages/cli/src/ui/utils/commandUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 32b75bfe674..448a958b831 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -25,7 +25,7 @@ export const CONTEXT_FILES_ANNOUNCEMENT_PREFIX = 'Read context files:'; /** Whether a history item is the context-files announcement. */ export function isContextFilesAnnouncement(item: { - type: MessageType; + type: string; text?: string; }): boolean { return ( From baed4d910d3ce8507979149ad59b12f34b940741 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Thu, 13 Aug 2026 21:44:27 +0800 Subject: [PATCH 13/17] =?UTF-8?q?fix(cli):=20address=20R11=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20mock=20params,=20type-check=20test,=20order?= =?UTF-8?q?ing=20assertion,=20positional=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Correct loadServerHierarchicalMemory mock parameter list in config.test.ts to match real signature (extensionContextFilePaths is slot 4, not slot 5) - Add 3 unit tests for isContextFilesAnnouncement type discriminant (non-INFO item with prefix must not match) - Replace invocationCallOrder[0] with findIndex-based assertion to isolate the announcement's own call index - Pin folderTrust slot to true in directoryCommand.test.tsx - Pin extensionContextFilePaths and contextRuleExcludes with distinct sentinels in performMemoryRefresh test to catch same-typed swap --- packages/cli/src/config/config.test.ts | 11 ++++--- packages/cli/src/ui/AppContainer.test.tsx | 19 ++++++++--- .../src/ui/commands/directoryCommand.test.tsx | 2 +- .../cli/src/ui/utils/commandUtils.test.ts | 33 +++++++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 150d74d1bf4..8ee169a3a13 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -234,11 +234,14 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { }, loadEnvironment: vi.fn(), loadServerHierarchicalMemory: vi.fn( - (cwd, dirs, debug, fileService, extensionPaths, _maxDirs) => + // Match the real signature: (cwd, includeDirs, fileService, + // extensionContextFilePaths, folderTrust, importFormat, + // contextRuleExcludes, options) + (cwd, _dirs, _fileService, extensionContextFilePaths) => Promise.resolve({ - memoryContent: extensionPaths?.join(',') || '', - fileCount: extensionPaths?.length || 0, - contextFilePaths: extensionPaths || [], + memoryContent: extensionContextFilePaths?.join(',') || '', + fileCount: extensionContextFilePaths?.length || 0, + contextFilePaths: extensionContextFilePaths || [], ruleCount: 0, conditionalRules: [], projectRoot: cwd || '/tmp', diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index d0ac02bdad3..3cae3d3001c 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6373,7 +6373,10 @@ describe('AppContainer State Management', () => { // The INFO item must be added before the submission is admitted, so it // renders above the prompt. expect(enqueueMessage).toHaveBeenCalled(); - expect(addItem.mock.invocationCallOrder[0]).toBeLessThan( + const announcementIndex = addItem.mock.calls.findIndex(([item]) => + isContextFilesAnnouncement(item), + ); + expect(addItem.mock.invocationCallOrder[announcementIndex]).toBeLessThan( enqueueMessage.mock.invocationCallOrder[0], ); @@ -6507,6 +6510,14 @@ describe('AppContainer State Management', () => { '/custom/workspace', ); vi.spyOn(mockConfig, 'isSafeMode').mockReturnValue(false); + // Pin distinct sentinels for same-typed slots 4 and 7 so a + // positional swap is caught. + vi.spyOn(mockConfig, 'getExtensionContextFilePaths').mockReturnValue([ + 'ext-context.md', + ]); + vi.spyOn(mockConfig, 'getContextRuleExcludes').mockReturnValue([ + 'exclude-rule', + ]); const setContextFilePathsSpy = vi.spyOn( mockConfig, 'setContextFilePaths', @@ -6537,10 +6548,10 @@ describe('AppContainer State Management', () => { '/custom/workspace', expect.anything(), expect.anything(), + ['ext-context.md'], + true, expect.anything(), - expect.anything(), - expect.anything(), - expect.anything(), + ['exclude-rule'], expect.anything(), ); expect(setContextFilePathsSpy).toHaveBeenCalledWith(['/custom/QWEN.md']); diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 82dd4de905e..a64e1c2abe3 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -278,7 +278,7 @@ describe('directoryCommand', () => { expect.arrayContaining([path.normalize('/home/user/new-project')]), expect.anything(), expect.anything(), - expect.anything(), + true, 'tree', expect.anything(), ); diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 7b9fd57028b..1b0a11ba06c 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,10 +14,12 @@ import { copyToClipboard, getUrlOpenCommand, CodePage, + CONTEXT_FILES_ANNOUNCEMENT_PREFIX, consumesContextAnnouncementLatch, findMidInputSlashCommand, findSlashCommandTokens, getBestSlashCommandMatch, + isContextFilesAnnouncement, } from './commandUtils.js'; import type { RecentSlashCommands } from '../hooks/useSlashCompletion.js'; import { CommandKind, type SlashCommand } from '../commands/types.js'; @@ -1343,3 +1345,34 @@ describe('consumesContextAnnouncementLatch', () => { ); }); }); + +describe('isContextFilesAnnouncement', () => { + it('matches an INFO item with the announcement prefix', () => { + expect( + isContextFilesAnnouncement({ + type: 'info', + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md`, + }), + ).toBe(true); + }); + + it('rejects a non-INFO item even when text starts with the prefix', () => { + // A user prompt literally starting with "Read context files:" must + // not be treated as the announcement after a rewind. + expect( + isContextFilesAnnouncement({ + type: 'user', + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} please`, + }), + ).toBe(false); + }); + + it('rejects an INFO item without the prefix', () => { + expect( + isContextFilesAnnouncement({ + type: 'info', + text: 'Memory refreshed successfully.', + }), + ).toBe(false); + }); +}); From d75e5abe3585a9b42662962f9c5fbd6511670a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 14 Aug 2026 11:56:48 +0800 Subject: [PATCH 14/17] fix(cli): add /resume to known-gap list in latch comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /resume of the current session wipes UI history (announcement INFO not persisted) without re-arming the latch — same class as /cd, /directory add, and performMemoryRefresh. Documented as a known gap; self-healing latch follow-up covers all centrally. --- packages/cli/src/ui/AppContainer.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7cc9b04643a..2714e1996ad 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2568,8 +2568,9 @@ export const AppContainer = (props: AppContainerProps) => { // (ESC, expansion errors) and built-in submit_prompt commands without // the modelInvocable flag are not re-armed here; consuming at the true // admission choke point is a deeper refactor deferred for this feature. - // Known gap: /cd, /directory add, and performMemoryRefresh swap the - // attached context-file set within the same session but do not re-arm + // Known gap: /cd, /directory add, performMemoryRefresh, and + // /resume of the current session swap or wipe the attached + // context-file set within the same session but do not re-arm // the latch, so the new file set is never announced after the first // prompt consumes it. A self-healing latch that watches the // context-file set would cover these centrally; deferred as a From 2f58f662d698e00a8b3035b3b58fdf8df14a9823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Fri, 14 Aug 2026 15:21:23 +0800 Subject: [PATCH 15/17] fix(cli): reconcile announcement latch on any history replacement Wrap loadHistory with latch reconciliation: after rewind, /restore, or same-id /resume, the latch is set from whether the restored history contains a context-files announcement. Fixes the /restore duplicate announcement and the same-id /resume missing-announcement gap; replaces the rewind path's explicit hunk with the same shape. Also document the intentional fileCount vs contextFilePaths criteria difference in the loader, and fix a pre-existing test bug: the remount-only refresh test's mock destructuring was missing the history parameter, so it never actually captured refreshStatic. --- packages/cli/src/ui/AppContainer.test.tsx | 18 ++++++++- packages/cli/src/ui/AppContainer.tsx | 46 ++++++++++++++-------- packages/core/src/utils/memoryDiscovery.ts | 6 ++- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 3cae3d3001c..7659f2fac01 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -1377,6 +1377,7 @@ describe('AppContainer State Management', () => { ( _config, _settings, + _history, _addItem, _clearItems, _loadHistory, @@ -1394,10 +1395,25 @@ describe('AppContainer State Management', () => { }, ); + // remount-only behavior holds in VP mode, where refreshStatic must + // not clear the terminal. + const vpSettings = { + merged: { + hideTips: false, + theme: 'default', + ui: { + showStatusInTitle: false, + hideWindowTitle: false, + useTerminalBuffer: true, + }, + }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + render( , diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 2714e1996ad..1035619fa95 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -861,6 +861,21 @@ export const AppContainer = (props: AppContainerProps) => { useEffect(() => { contextFilesAnnouncedRef.current = false; }, [sessionStats.sessionId]); + // Wrap loadHistory to reconcile the announcement latch after any history + // replacement (rewind, /restore, /resume of the current session). If the + // restored history contains a context-files announcement the latch is + // consumed (prevents duplicates); otherwise it's armed (allows + // re-announcement). This covers /restore and same-id /resume, which the + // sessionId effect does not catch because the id is unchanged. + const loadHistoryWithLatchReconciliation = useCallback( + (newHistory: HistoryItem[]) => { + historyManager.loadHistory(newHistory); + contextFilesAnnouncedRef.current = newHistory.some( + isContextFilesAnnouncement, + ); + }, + [historyManager], + ); const activeWorktree = useMemo( () => worktreeSession @@ -962,7 +977,7 @@ export const AppContainer = (props: AppContainerProps) => { collapseOnResume, collapsePreviewCount, ); - historyManager.loadHistory(historyItems); + loadHistoryWithLatchReconciliation(historyItems); // Seed the prompt counter from the resumed conversation so new // promptIds don't collide with restored file history snapshots. @@ -1889,7 +1904,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.history, historyManager.addItem, historyManager.clearItems, - historyManager.loadHistory, + loadHistoryWithLatchReconciliation, refreshStatic, toggleVimEnabled, isProcessing, @@ -2568,13 +2583,13 @@ export const AppContainer = (props: AppContainerProps) => { // (ESC, expansion errors) and built-in submit_prompt commands without // the modelInvocable flag are not re-armed here; consuming at the true // admission choke point is a deeper refactor deferred for this feature. - // Known gap: /cd, /directory add, performMemoryRefresh, and - // /resume of the current session swap or wipe the attached - // context-file set within the same session but do not re-arm + // Known gap: /cd, /directory add, and performMemoryRefresh swap the + // attached context-file set within the same session but do not re-arm // the latch, so the new file set is never announced after the first // prompt consumes it. A self-healing latch that watches the // context-file set would cover these centrally; deferred as a - // follow-up. + // follow-up. (/restore and same-id /resume are handled by the + // loadHistory wrapper above.) const trimmedPrompt = userPromptText.trim(); if ( !contextFilesAnnouncedRef.current && @@ -3732,16 +3747,7 @@ export const AppContainer = (props: AppContainerProps) => { originalHistory.filter((h) => h.id < userItem.id), ); clearPendingStateRef.current(); - historyManager.loadHistory(truncatedUi); - - // Re-arm the latch iff the rewound history no longer contains the - // announcement. Rewinding to a turn before files were attached - // filters the INFO out while the files stay in the system prompt, - // so the next prompt must re-announce; rewinding to a turn at/after - // the announcement keeps the INFO and must not duplicate it. - contextFilesAnnouncedRef.current = truncatedUi.some( - isContextFilesAnnouncement, - ); + loadHistoryWithLatchReconciliation(truncatedUi); refreshStatic(); @@ -3812,7 +3818,13 @@ export const AppContainer = (props: AppContainerProps) => { setIsRewindSelectorOpen(false); } }, - [config, historyManager, refreshStatic, buffer], + [ + config, + historyManager, + loadHistoryWithLatchReconciliation, + refreshStatic, + buffer, + ], ); const handleDoubleEscRewind = useDoublePress(openRewindSelector, (pending) => diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 5c92e36aec0..e4b3afc85c2 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -540,7 +540,11 @@ export async function loadServerHierarchicalMemory( ); // Only count files that match configured memory filenames (e.g., QWEN.md), - // excluding system context files like output-language.md + // excluding system context files like output-language.md. Note: this is + // intentionally different from contextFilePaths below, which is + // content-based and includes non-memory-named files. The two surfaces + // (/memory count vs announcement list) may differ; aligning them at + // the display site is deferred as a follow-up. const memoryFilenames = new Set([ ...getAllGeminiMdFilenames(), LOCAL_CONTEXT_FILENAME, From e9fb9981177261b890ffe064e2f11df2cb0caa24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Mon, 17 Aug 2026 10:31:09 +0800 Subject: [PATCH 16/17] fix(cli): route interactive /resume through latch wrapper and sanitize markers useResumeCommand now accepts an optional loadHistory override; AppContainer passes the latch-reconciling wrapper so interactive /resume (including same-id) reconciles the latch instead of leaving it consumed with no announcement in the rebuilt history. The wrapper also destructures loadHistory so its useCallback deps hold the stable function reference rather than the per-mutation historyManager identity, keeping history out of commandContext's rebuild path. concatenateInstructions sanitizes the marker displayPath with stripAnsiAndControl: newline/control characters in directory names could previously forge or hide entries in the /context parser, contradicting the sanitized announcement surface. Documented that contextFilePaths lists top-level files only (@import content is inlined into its importer). Added tests for the sessionId re-arm effect, the startup-resume armed latch, and the resume override. --- packages/cli/src/ui/AppContainer.test.tsx | 84 ++++++++++++++++++- packages/cli/src/ui/AppContainer.tsx | 15 +++- .../cli/src/ui/hooks/useResumeCommand.test.ts | 78 +++++++++++++++++ packages/cli/src/ui/hooks/useResumeCommand.ts | 11 ++- packages/core/src/utils/memoryDiscovery.ts | 22 +++-- 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 7659f2fac01..14fb0a58296 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6333,13 +6333,14 @@ describe('AppContainer State Management', () => { describe('context files announcement (#5267)', () => { const renderAnnouncementHarness = (contextFilePaths: string[]) => { const addItem = vi.fn(); + const loadHistory = vi.fn(); const enqueueMessage = vi.fn(); mockedUseHistory.mockReturnValue({ history: [], addItem, updateItem: vi.fn(), clearItems: vi.fn(), - loadHistory: vi.fn(), + loadHistory, truncateToItem: vi.fn(), }); mockedUseMessageQueue.mockReturnValue({ @@ -6355,7 +6356,7 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getContextFilePaths').mockReturnValue( contextFilePaths, ); - render( + const view = render( { initializationResult={mockInitResult} />, ); - return { addItem, enqueueMessage }; + return { addItem, enqueueMessage, loadHistory, view }; }; const announcementCalls = (addItem: ReturnType) => @@ -6467,6 +6468,83 @@ describe('AppContainer State Management', () => { expect(announcementCalls(addItem)).toHaveLength(2); }); + it('re-arms the latch when sessionStats.sessionId changes (startNewSession)', () => { + mockedUseSessionStats.mockReturnValue({ + stats: { sessionId: 'session-a' }, + seedPromptCount: vi.fn(), + }); + const { addItem, view } = renderAnnouncementHarness(['QWEN.md']); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + + // /clear flows through SessionContext.startNewSession, which swaps + // the session id. The effect must re-arm the latch so the new + // session's first prompt re-announces the still-attached files. + mockedUseSessionStats.mockReturnValue({ + stats: { sessionId: 'session-b' }, + seedPromptCount: vi.fn(), + }); + act(() => { + view.rerender( + , + ); + }); + + capturedUIActions.handleFinalSubmit('again', { + submittedPrompt: 'again', + }); + expect(announcementCalls(addItem)).toHaveLength(2); + }); + + it('arms the latch after a startup --resume restore (announcement is UI-only)', async () => { + vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); + vi.spyOn(mockConfig, 'getResumedSessionData').mockReturnValue({ + conversation: { + sessionId: 'session-1', + projectHash: 'test-project-hash', + startTime: '2024-01-01T00:00:00Z', + lastUpdated: '2024-01-01T00:00:01Z', + messages: [ + { + uuid: 'u1', + parentUuid: null, + sessionId: 'session-1', + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + cwd: '/test/workspace', + version: '1.0.0', + }, + ], + }, + filePath: '/tmp/session.jsonl', + lastCompletedUuid: 'u1', + } as ReturnType); + vi.spyOn(mockConfig, 'loadPausedBackgroundAgents').mockResolvedValue([]); + const { addItem, loadHistory } = renderAnnouncementHarness(['QWEN.md']); + + // The startup resume path must route through the reconciling + // wrapper: the rebuilt history has no announcement (the INFO is + // UI-only and never persisted), so the latch stays armed and the + // next prompt announces. + await vi.waitFor(() => { + expect(loadHistory).toHaveBeenCalled(); + }); + + capturedUIActions.handleFinalSubmit('hello', { + submittedPrompt: 'hello', + }); + expect(announcementCalls(addItem)).toHaveLength(1); + }); + it('does not consume the latch on a whitespace-only prompt', () => { const { addItem } = renderAnnouncementHarness(['QWEN.md']); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1035619fa95..c02d525262d 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -867,14 +867,21 @@ export const AppContainer = (props: AppContainerProps) => { // consumed (prevents duplicates); otherwise it's armed (allows // re-announcement). This covers /restore and same-id /resume, which the // sessionId effect does not catch because the id is unchanged. + // Destructure loadHistory so the useCallback can depend on the function + // (stable, empty-deps) instead of the whole historyManager object, whose + // identity changes on every history mutation — passing the wrapper into + // useSlashCommandProcessor would otherwise put `history` back into the + // commandContext useMemo deps (the historyRef pattern exists to avoid + // exactly that). + const { loadHistory: rawLoadHistory } = historyManager; const loadHistoryWithLatchReconciliation = useCallback( (newHistory: HistoryItem[]) => { - historyManager.loadHistory(newHistory); + rawLoadHistory(newHistory); contextFilesAnnouncedRef.current = newHistory.some( isContextFilesAnnouncement, ); }, - [historyManager], + [rawLoadHistory], ); const activeWorktree = useMemo( () => @@ -1621,6 +1628,10 @@ export const AppContainer = (props: AppContainerProps) => { config, settings, historyManager, + // Route the interactive /resume through the latch-reconciling wrapper + // so same-id resume (no sessionId change → no effect re-arm) still + // re-arms the latch when the rebuilt history has no announcement. + loadHistory: loadHistoryWithLatchReconciliation, startNewSession, clearPendingState: clearPendingStateFromRef, setSessionName, diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 8bfe6a67858..ab5c5ee79b5 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -335,6 +335,84 @@ describe('useResumeCommand', () => { expect(config.getGoalRuntimeReady).toHaveBeenCalledTimes(1); }); + it('handleResume routes history replacement through the loadHistory override', async () => { + resumeMocks.reset(); + resumeMocks.createPendingLoadSession(); + + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + const overrideLoadHistory = vi.fn(); + + const config = { + getSessionId: () => 'old-session-id', + getTargetDir: () => '/tmp', + getGeminiClient: () => ({ + initialize: vi.fn().mockResolvedValue(undefined), + }), + startNewSession: vi.fn(), + getGoalRuntimeReady: vi.fn().mockResolvedValue({}), + getBackgroundTaskRegistry: () => ({ + hasRunningTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getWorkflowRunRegistry: () => ({ + hasRunningEntries: vi.fn().mockReturnValue(false), + reset: vi.fn(), + abortAll: vi.fn(), + }), + loadPausedBackgroundAgents: vi.fn().mockResolvedValue([]), + getBackgroundAgentResumeService: () => ({ + buildRecoveredBackgroundAgentsNotice: vi.fn(), + }), + getChatRecordingService: () => ({ rebuildTurnBoundaries: vi.fn() }), + getDebugLogger: () => ({ + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), + } as unknown as import('@qwen-code/qwen-code-core').Config; + + const { result } = renderHook(() => + useResumeCommand({ + config, + settings: mockSettings, + historyManager, + // AppContainer passes its latch-reconciling wrapper here; the + // rebuilt history must flow through it, not the raw manager. + loadHistory: overrideLoadHistory, + startNewSession: vi.fn(), + }), + ); + + resumeMocks.resolvePendingLoadSession({ + conversation: resumeMocks.makeConversation([ + { role: 'user', parts: [{ text: 'hello' }] }, + ]), + }); + await act(async () => { + await result.current.handleResume('session-2'); + }); + + expect(overrideLoadHistory).toHaveBeenCalledTimes(1); + expect(overrideLoadHistory).toHaveBeenCalledWith( + expect.arrayContaining([expect.anything()]), + ); + expect(historyManager.loadHistory).not.toHaveBeenCalled(); + expect(historyManager.clearItems).toHaveBeenCalledTimes(1); + }); + it('adds a recovery notice when resuming an interrupted tool turn', async () => { resumeMocks.reset(); resumeMocks.createPendingLoadSession(); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index c85314524a4..20f83a0d8d0 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -32,6 +32,13 @@ export interface UseResumeCommandOptions { UseHistoryManagerReturn, 'addItem' | 'clearItems' | 'loadHistory' >; + /** + * Optional override for history replacement. AppContainer passes a + * latch-reconciling wrapper here so same-id resume (which changes no + * sessionId the re-arm effect could observe) still reconciles the + * context-files announcement latch. Defaults to historyManager.loadHistory. + */ + loadHistory?: UseHistoryManagerReturn['loadHistory']; startNewSession: (sessionId: string) => void; clearPendingState?: () => void; setSessionName?: (name: string | null) => void; @@ -81,13 +88,15 @@ export function useResumeCommand( config, settings, historyManager, + loadHistory: loadHistoryOverride, startNewSession, clearPendingState, setSessionName, remount, } = options; - const { addItem, clearItems, loadHistory } = historyManager; + const { addItem, clearItems } = historyManager; + const loadHistory = loadHistoryOverride ?? historyManager.loadHistory; const handleResume = useCallback( async (sessionId: string) => { if (!config) { diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index e4b3afc85c2..2261d520baa 100644 --- a/packages/core/src/utils/memoryDiscovery.ts +++ b/packages/core/src/utils/memoryDiscovery.ts @@ -360,9 +360,15 @@ function concatenateInstructions( .filter(hasAttachedContent) .map((item) => { const trimmedContent = (item.content as string).trim(); - const displayPath = path.isAbsolute(item.filePath) - ? path.relative(currentWorkingDirectoryForDisplay, item.filePath) - : item.filePath; + // Sanitize the marker path: paths under attacker-influenceable + // directory names could otherwise forge or hide entries in the + // `/context` parser (newline/control chars break its line-oriented + // markers), contradicting the sanitized announcement surface. + const displayPath = stripAnsiAndControl( + path.isAbsolute(item.filePath) + ? path.relative(currentWorkingDirectoryForDisplay, item.filePath) + : item.filePath, + ); return `--- Context from: ${displayPath} ---\n${trimmedContent}\n--- End of Context from: ${displayPath} ---`; }) .join('\n\n'); @@ -376,6 +382,8 @@ export interface LoadServerHierarchicalMemoryResponse { * inside the CWD tree, `~/...` shortcuts for files under the user home. * Display-only — do not resolve them against the CWD. * Lets callers tell users which files were actually attached (see #5267). + * Top-level files only: content pulled in via `@import` is inlined into + * the importing file and is not listed separately. * Baseline rules (`.qwen/rules/`) are injected separately and deliberately * not listed here (see `ruleCount`). */ @@ -553,9 +561,11 @@ export async function loadServerHierarchicalMemory( memoryFilenames.has(path.basename(item.filePath)), ); fileCount = memoryItems.length; - // Announce every file whose content actually reached the system prompt - // (see hasAttachedContent) — not just memory-named files — so the list - // matches what concatenateInstructions attached. + // Announce every top-level file whose content actually reached the + // system prompt (see hasAttachedContent) — not just memory-named files — + // so the list matches what concatenateInstructions attached. Files + // pulled in via @import are inlined into their importer's content and + // are not listed separately. contextFilePaths = contentsWithPaths .filter(hasAttachedContent) .map((item) => From 65a61696989ed2b912d1ad05820a789dad17f77d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BF=8A=E8=89=AF?= Date: Tue, 18 Aug 2026 14:24:39 +0800 Subject: [PATCH 17/17] test(cli): stub initialize in sessionId re-arm test to stop unhandled rejection --- packages/cli/src/ui/AppContainer.test.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 1ceacc5519c..b63154127e1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -6522,6 +6522,12 @@ describe('AppContainer State Management', () => { }); it('re-arms the latch when sessionStats.sessionId changes (startNewSession)', () => { + // Scoped stub: React's double-mount re-runs the mount init effect and + // the second initialize() throws inside an un-awaited IIFE, surfacing + // as an unhandled rejection when this test runs in isolation (-t + // filtered, watch mode, or sharded runs exit 1 because of it). + vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined); + mockedUseSessionStats.mockReturnValue({ stats: { sessionId: 'session-a' }, seedPromptCount: vi.fn(),