diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 2022b3156ef..67857a4ddae 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -234,10 +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, + 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 9f9e22be8a5..b63154127e1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -94,6 +94,11 @@ import { StreamingState, 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'; @@ -182,6 +187,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'; @@ -540,10 +554,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'), @@ -625,6 +641,12 @@ describe('AppContainer State Management', () => { rewindRecording, } as unknown as NonNullable>); + if (options.contextFilePaths) { + vi.spyOn(mockConfig, 'getContextFilePaths').mockReturnValue( + options.contextFilePaths, + ); + } + render( { ( _config, _settings, + _history, _addItem, _clearItems, _loadHistory, @@ -1372,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( , @@ -5990,6 +6028,76 @@ 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. 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' }, + rewindUserItem(3, 'second prompt', 'prompt-2'), + { + id: 4, + type: MessageType.INFO, + text: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md`, + }, + ]; + const harness = renderRewindHarness({ + history, + 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 announcementsAfter = harness.addItem.mock.calls.filter(([item]) => + isContextFilesAnnouncement(item), + ); + expect(announcementsAfter).toHaveLength(2); + }); + + 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: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} 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]) => + isContextFilesAnnouncement(item), + ); + expect(announcements).toHaveLength(0); + }); + it('restores code only without truncating conversation history', async () => { const harness = renderRewindHarness(); @@ -6274,6 +6382,334 @@ describe('AppContainer State Management', () => { ).toBe(false); }); }); + + 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, + 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, + ); + const view = render( + , + ); + return { addItem, enqueueMessage, loadHistory, view }; + }; + + const announcementCalls = (addItem: ReturnType) => + addItem.mock.calls.filter(([item]) => isContextFilesAnnouncement(item)); + + it('announces loaded context files above the first real prompt, once', () => { + const { addItem, enqueueMessage } = 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: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} QWEN.md, ~/.qwen/QWEN.md`, + }), + expect.any(Number), + ); + // The INFO item must be added before the submission is admitted, so it + // renders above the prompt. + expect(enqueueMessage).toHaveBeenCalled(); + const announcementIndex = addItem.mock.calls.findIndex(([item]) => + isContextFilesAnnouncement(item), + ); + expect(addItem.mock.invocationCallOrder[announcementIndex]).toBeLessThan( + enqueueMessage.mock.invocationCallOrder[0], + ); + + 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, 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('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('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(), + }); + 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']); + + // 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); + }); + + 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('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); + // 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', + ); + + 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(), + ['ext-context.md'], + true, + expect.anything(), + ['exclude-rule'], + expect.anything(), + ); + expect(setContextFilePathsSpy).toHaveBeenCalledWith(['/custom/QWEN.md']); + }); + }); }); describe('dedupeNewestFirst', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c6928345c8c..20794af2427 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -168,7 +168,13 @@ 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 { + CONTEXT_FILES_ANNOUNCEMENT_PREFIX, + consumesContextAnnouncementLatch, + isBtwCommand, + isContextFilesAnnouncement, + isSlashCommand, +} from './utils/commandUtils.js'; import { detectWorkflowKeyword, buildWorkflowSteeringNotice, @@ -844,6 +850,39 @@ 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); + // /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]); + // 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. + // 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[]) => { + rawLoadHistory(newHistory); + contextFilesAnnouncedRef.current = newHistory.some( + isContextFilesAnnouncement, + ); + }, + [rawLoadHistory], + ); const activeWorktree = useMemo( () => worktreeSession @@ -945,7 +984,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. @@ -1589,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, @@ -1872,7 +1915,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.history, historyManager.addItem, historyManager.clearItems, - historyManager.loadHistory, + loadHistoryWithLatchReconciliation, refreshStatic, toggleVimEnabled, isProcessing, @@ -2015,6 +2058,7 @@ export const AppContainer = (props: AppContainerProps) => { if (config.isSafeMode()) { config.setUserMemory(''); config.setGeminiMdFileCount(0); + config.setContextFilePaths([]); config.setConditionalRulesRegistry( new ConditionalRulesRegistry([], config.getWorkingDir()), ); @@ -2037,27 +2081,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( + config.getWorkingDir(), + 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), ); @@ -2537,6 +2587,44 @@ export const AppContainer = (props: AppContainerProps) => { void handleSlashCommand('/quit'); return; } + // 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. + // 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. (/restore and same-id /resume are handled by the + // loadHistory wrapper above.) + const trimmedPrompt = userPromptText.trim(); + if ( + !contextFilesAnnouncedRef.current && + consumesContextAnnouncementLatch(trimmedPrompt, { + shellModeActive, + slashCommands, + }) + ) { + const contextFilePaths = config.getContextFilePaths(); + if (contextFilePaths.length > 0) { + // 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: `${CONTEXT_FILES_ANNOUNCEMENT_PREFIX} ${contextFilePaths.join(', ')}`, + }, + Date.now(), + ); + } + } const recoveredAgentsNotice = !isSlashCommand(userPromptText) && !isBtwCommand(userPromptText) ? config.consumePendingRecoveredAgentsNotice() @@ -2759,6 +2847,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager, settings.merged.ui?.disableWorkflowKeywordTrigger, setBufferText, + shellModeActive, vimEnabled, ], ); @@ -3018,6 +3107,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(); @@ -3672,7 +3764,7 @@ export const AppContainer = (props: AppContainerProps) => { originalHistory.filter((h) => h.id < userItem.id), ); clearPendingStateRef.current(); - historyManager.loadHistory(truncatedUi); + loadHistoryWithLatchReconciliation(truncatedUi); refreshStatic(); @@ -3743,7 +3835,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/cli/src/ui/commands/contextCommand.test.ts b/packages/cli/src/ui/commands/contextCommand.test.ts index c49ad7e1fbb..1b6472a3a94 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 { @@ -57,6 +59,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; } @@ -89,6 +92,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; }); @@ -212,6 +216,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); @@ -258,6 +263,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); @@ -286,6 +292,56 @@ describe('collectContextData (contextCommand)', () => { 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, + // 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 = + `--- 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')); + }); + + 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')); + }); + it('excludes disabled skills from the detail breakdown', async () => { const config = { ...makeMockConfig(), diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 44b13c0b404..6dbeb258f6e 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 @@ -71,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[] = []; @@ -84,7 +89,14 @@ function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { const filePath = match[1]!; const content = match[2]!; results.push({ - path: filePath, + // 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(workingDir, filePath), + workingDir, + ), tokens: estimateTokens(content), }); } @@ -171,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/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index e4d0e29bc64..a64e1c2abe3 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,46 @@ 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'), + ); + + // 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(), + true, + 'tree', + expect.anything(), + ); + 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/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/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/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 8e1d8502521..1b0a11ba06c 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,11 +14,15 @@ 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'; // Mock child_process vi.mock('child_process'); @@ -1259,3 +1263,116 @@ 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 (fork via runForkedAgent, no main turn)', () => { + expect( + 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, + ); + }); + + 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, + ); + }); +}); + +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); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 255afba1976..a6c73cde286 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -10,12 +10,31 @@ 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'; +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: string; + 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. * @@ -111,6 +130,55 @@ 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. + * 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 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 + * 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, + options: { + shellModeActive: boolean; + slashCommands: readonly SlashCommand[]; + }, +): boolean { + if (trimmedPrompt.length === 0) { + return false; + } + // 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)) { + // 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 — 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 + ); + } + 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/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 d3c94c9eab9..af74e3122fb 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -188,6 +188,7 @@ vi.mock('../utils/memoryDiscovery.js', () => ({ loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ memoryContent: '', fileCount: 0, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -5655,6 +5656,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', @@ -5705,6 +5707,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot, @@ -5774,6 +5777,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: '--- Context from: QWEN.md ---\nProject rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot, @@ -5812,6 +5816,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', @@ -5845,6 +5850,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', @@ -5890,6 +5896,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', @@ -5935,6 +5942,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', @@ -5962,6 +5970,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', @@ -5980,6 +5989,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, @@ -5989,6 +6026,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({ memoryContent: 'short project rules', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6017,6 +6055,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: 'a'.repeat(800), fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6076,6 +6115,7 @@ describe('Server Config (config.ts)', () => { vi.mocked(loadServerHierarchicalMemory).mockResolvedValueOnce({ memoryContent: 'short project context', fileCount: 1, + contextFilePaths: [], ruleCount: 0, conditionalRules: [], projectRoot: '/tmp', @@ -6856,6 +6896,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', @@ -6881,6 +6922,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', @@ -6904,6 +6946,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', @@ -6952,6 +6995,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', @@ -6974,6 +7018,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 2357466a47a..5f23322e70e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1938,6 +1938,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; @@ -3465,29 +3466,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 @@ -3616,6 +3623,7 @@ export class Config { this.autoMemoryPrompt = ''; } this.setGeminiMdFileCount(fileCount); + this.setContextFilePaths(contextFilePaths); this.conditionalRulesRegistry = new ConditionalRulesRegistry( conditionalRules, projectRoot, @@ -6221,6 +6229,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..2a42eb6f871 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,12 +472,54 @@ 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), }); }); + 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 '); + + 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), @@ -846,6 +916,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 +1353,85 @@ 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(path.join(proj, 'QWEN.md'), proj)).toBe( + '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', () => { + 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(projUnderHome, 'QWEN.md'), + projUnderHome, + ), + ).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( + path.relative(proj, file), + ); + }); + + it('keeps relative paths for files outside both CWD and home', () => { + 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'); + }); + + it('strips ANSI escapes and control characters from display paths', () => { + // 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'), + proj, + ), + ).toBe('a.md'); + }); +}); diff --git a/packages/core/src/utils/memoryDiscovery.ts b/packages/core/src/utils/memoryDiscovery.ts index 1301bab59b7..2261d520baa 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'; @@ -314,30 +315,79 @@ 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). Output + * is sanitized because directory names are attacker-influenceable. + */ +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); + // 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); + } + } + 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; + // 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} ---`; }) - .filter((block): block is string => block !== null) .join('\n\n'); } export interface LoadServerHierarchicalMemoryResponse { memoryContent: string; fileCount: number; + /** + * 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). + * 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`). + */ + contextFilePaths: string[]; /** Number of baseline rules injected at session start. */ ruleCount: number; /** Conditional rules (with `paths:`) for turn-level lazy injection. */ @@ -476,6 +526,7 @@ export async function loadServerHierarchicalMemory( let combinedInstructions = ''; let fileCount = 0; + let contextFilePaths: string[] = []; if (filePaths.length > 0) { const loadReason = options.loadReason ?? 'session_start'; @@ -497,14 +548,33 @@ 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, ]); - fileCount = contentsWithPaths.filter((item) => + const memoryItems = contentsWithPaths.filter((item) => memoryFilenames.has(path.basename(item.filePath)), - ).length; + ); + fileCount = memoryItems.length; + // 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) => + formatContextFileDisplayPath( + item.filePath, + currentWorkingDirectory, + userHomePath, + ), + ); } // Load path-based context rules from .qwen/rules/ directories. @@ -531,6 +601,7 @@ export async function loadServerHierarchicalMemory( return { memoryContent, fileCount, + contextFilePaths, ruleCount, conditionalRules, projectRoot: effectiveRoot, diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index 9d3f69aee23..36abd07544e 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -65,10 +65,13 @@ const UNESCAPE_REGEX = (() => { /** * Replaces the home directory with a tilde. * @param filePath - The path to tildeify. + * @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): string { - const rawHomeDir = os.homedir(); +export function tildeifyPath(filePath: string, homeOverride?: string): string { + const rawHomeDir = homeOverride ?? os.homedir(); if (!rawHomeDir) { return filePath; }