Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c954b47
feat(hooks): add InstructionsLoaded event
qqqys Jun 1, 2026
0769aeb
fix(hooks): align InstructionsLoaded branch with main config
qqqys Jun 1, 2026
3fe3b2a
fix(hooks): remove stale branch regressions
qqqys Jun 1, 2026
12f874d
fix(hooks): restore main config behavior
qqqys Jun 1, 2026
2479f7e
Merge upstream/main into feat/instructions-loaded-hook
qqqys Jun 2, 2026
743f306
fix(core): tighten instructions loaded notifications
qqqys Jun 2, 2026
8ddb096
test(core): cover flat instruction import notifications
qqqys Jun 2, 2026
2373477
fix(core): order instruction load notifications
qqqys Jun 2, 2026
9693ed6
test(core): cover instructions loaded callback
qqqys Jun 2, 2026
69ce8ac
fix(hooks): include instruction path in warning
qqqys Jun 3, 2026
c8bbc14
Merge remote-tracking branch 'refs/remotes/upstream/main' into HEAD
qqqys Jun 3, 2026
372eb2c
fix(cli): align prompt width with approval visuals
qqqys Jun 3, 2026
604e741
test(cli): update hook event count for InstructionsLoaded
qqqys Jun 3, 2026
c85b8e3
fix(core): tighten InstructionsLoaded metadata
qqqys Jun 3, 2026
34b0d50
fix(core): preserve instruction memory type for nested imports
qqqys Jun 4, 2026
73ae1ab
docs(core): clarify imported memory type
qqqys Jun 4, 2026
a52a6b1
Merge upstream/main into instructions loaded hook
qqqys Jun 5, 2026
6cbe6b7
fix(core): classify home-root instructions as project
qqqys Jun 6, 2026
e66884a
fix(core): isolate instruction import callback failures
qqqys Jun 6, 2026
b499818
fix(core): notify instructions after import processing
qqqys Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
FileDiscoveryService,
getAllGeminiMdFilenames,
loadServerHierarchicalMemory,
type LoadServerHierarchicalMemoryOptions,
type LoadServerHierarchicalMemoryResponse,
setGeminiMdFilename as setServerGeminiMdFilename,
resolveTelemetrySettings,
Expand Down Expand Up @@ -1130,6 +1131,7 @@ export async function loadHierarchicalGeminiMemory(
folderTrust: boolean,
memoryImportFormat: 'flat' | 'tree' = 'tree',
contextRuleExcludes: string[] = [],
options: LoadServerHierarchicalMemoryOptions = {},
): Promise<LoadServerHierarchicalMemoryResponse> {
// FIX: Use real, canonical paths for a reliable comparison to handle symlinks.
const realCwd = fs.realpathSync(path.resolve(currentWorkingDirectory));
Expand All @@ -1149,6 +1151,7 @@ export async function loadHierarchicalGeminiMemory(
folderTrust,
memoryImportFormat,
contextRuleExcludes,
options,
);
}

Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
createInstructionsLoadedCallback,
SessionEndReason,
generatePromptSuggestion,
logPromptSuggestion,
Expand Down Expand Up @@ -1324,6 +1325,11 @@ export const AppContainer = (props: AppContainerProps) => {
config.isTrustedFolder(),
settings.merged.context?.importFormat || 'tree', // Use setting or default to 'tree'
config.getContextRuleExcludes(),
{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The onInstructionsLoaded callback creation is duplicated verbatim across three call sites (here, directoryCommand.tsx:245, and the config.ts refresh path). Each constructs an identical createInstructionsLoadedCallback(() => config.getHookSystem()) with the same loadReason: 'refresh'.

Consider extracting a shared helper, e.g.:

Suggested change
{
createRefreshMemoryOptions(config),

where createRefreshMemoryOptions lives alongside createInstructionsLoadedCallback and returns the { loadReason, onInstructionsLoaded } object. This avoids drift if the callback shape changes and makes the three call sites trivially consistent.

— qwen3.7-max via Qwen Code /review

onInstructionsLoaded: createInstructionsLoadedCallback(() =>
config.getHookSystem(),
),
},
);

config.setUserMemory(memoryContent);
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/ui/commands/directoryCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as path from 'node:path';
import {
loadServerHierarchicalMemory,
ConditionalRulesRegistry,
createInstructionsLoadedCallback,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
import { SettingScope } from '../../config/settings.js';
Expand Down Expand Up @@ -241,6 +242,11 @@ export const directoryCommand: SlashCommand = {
context.services.settings.merged.context?.importFormat ||
'tree', // Use setting or default to 'tree'
config.getContextRuleExcludes(),
{
onInstructionsLoaded: createInstructionsLoadedCallback(() =>
config.getHookSystem(),
),
},
);
config.setUserMemory(memoryContent);
config.setGeminiMdFileCount(fileCount);
Expand Down
32 changes: 30 additions & 2 deletions packages/cli/src/ui/components/hooks/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ describe('hooks constants', () => {
expect(exitCodes).toHaveLength(3);
});

it('should return exit codes for InstructionsLoaded event', () => {
const exitCodes = getHookExitCodes(HookEventName.InstructionsLoaded);
expect(exitCodes).toHaveLength(2);
});

it('should return exit codes for PostCompact event', () => {
const exitCodes = getHookExitCodes(HookEventName.PostCompact);
expect(exitCodes).toHaveLength(2);
Expand Down Expand Up @@ -138,6 +143,11 @@ describe('hooks constants', () => {
expect(desc).toBe('When a new session is started');
});

it('should return description for InstructionsLoaded', () => {
const desc = getHookShortDescription(HookEventName.InstructionsLoaded);
expect(desc).toBe('When instruction files are loaded');
});

it('should return description for PostCompact', () => {
const desc = getHookShortDescription(HookEventName.PostCompact);
expect(desc).toBe('After conversation compaction');
Expand Down Expand Up @@ -185,6 +195,13 @@ describe('hooks constants', () => {
expect(desc).toBe('');
});

it('should return description for InstructionsLoaded', () => {
const desc = getHookDescription(HookEventName.InstructionsLoaded);
expect(desc).toContain('file_path');
expect(desc).toContain('memory_type');
expect(desc).toContain('load_reason');
});

it('should return description for PostCompact', () => {
const desc = getHookDescription(HookEventName.PostCompact);
expect(desc).toContain('trigger');
Expand Down Expand Up @@ -243,10 +260,11 @@ describe('hooks constants', () => {
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PermissionDenied);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.TodoCreated);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.TodoCompleted);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.InstructionsLoaded);
});

it('should have 17 events', () => {
expect(DISPLAY_HOOK_EVENTS).toHaveLength(17);
it('should have 18 events', () => {
expect(DISPLAY_HOOK_EVENTS).toHaveLength(18);
});
});

Expand Down Expand Up @@ -352,5 +370,15 @@ describe('hooks constants', () => {
expect(info.exitCodes).toHaveLength(3);
expect(info.matcherGroups).toEqual([]);
});

it('should create empty info for InstructionsLoaded', () => {
const info = createEmptyHookEventInfo(HookEventName.InstructionsLoaded);

expect(info.event).toBe(HookEventName.InstructionsLoaded);
expect(info.shortDescription).toBe('When instruction files are loaded');
expect(info.description).toContain('file_path');
expect(info.exitCodes).toHaveLength(2);
expect(info.matcherGroups).toEqual([]);
});
});
});
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/hooks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ export function getHookExitCodes(eventName: string): HookExitCode[] {
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.InstructionsLoaded]: [
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.UserPromptSubmit]: [
{ code: 0, description: t('stdout shown to Qwen') },
{
Expand Down Expand Up @@ -145,6 +149,7 @@ export function getHookShortDescription(eventName: string): string {
[HookEventName.PostToolUse]: t('After tool execution'),
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
[HookEventName.Notification]: t('When notifications are sent'),
[HookEventName.InstructionsLoaded]: t('When instruction files are loaded'),
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
[HookEventName.SessionStart]: t('When a new session is started'),
[HookEventName.Stop]: t('Right before Qwen Code concludes its response'),
Expand Down Expand Up @@ -190,6 +195,9 @@ export function getHookDescription(eventName: string): string {
[HookEventName.Notification]: t(
'Input to command is JSON with notification message and type.',
),
[HookEventName.InstructionsLoaded]: t(
'Input to command is JSON with file_path, memory_type, load_reason, and optional trigger_file_path and parent_file_path.',
),
[HookEventName.UserPromptSubmit]: t(
'Input to command is JSON with original user prompt text.',
),
Expand Down
70 changes: 68 additions & 2 deletions packages/core/src/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import { ToolNames } from '../tools/tool-names.js';
import { fireNotificationHook } from '../core/toolHookTriggers.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { loadServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { LoadServerHierarchicalMemoryOptions } from '../utils/memoryDiscovery.js';
import { readAutoMemoryIndex } from '../memory/store.js';
import { ExtensionManager } from '../extension/extensionManager.js';
import { SkillManager } from '../skills/skill-manager.js';
Expand Down Expand Up @@ -151,6 +152,29 @@ vi.mock('../hooks/index.js', () => {
return {
HookSystem: HookSystemMock,
createHookOutput: vi.fn(),
createInstructionsLoadedCallback:
(
getHookSystem: () => {
fireInstructionsLoadedEvent?: (...args: unknown[]) => unknown;
},
) =>
async (notification: {
filePath: string;
memoryType: string;
loadReason: string;
triggerFilePath?: string;
parentFilePath?: string;
}) => {
await getHookSystem()?.fireInstructionsLoadedEvent?.(
notification.filePath,
notification.memoryType,
notification.loadReason,
{
triggerFilePath: notification.triggerFilePath,
parentFilePath: notification.parentFilePath,
},
);
},
};
});

Expand Down Expand Up @@ -1440,7 +1464,7 @@ describe('Server Config (config.ts)', () => {
await config.refreshHierarchicalMemory();

const lastCall = vi.mocked(loadServerHierarchicalMemory).mock.calls.at(-1);
expect(lastCall?.at(-1)).toEqual({ explicitOnly: true });
expect(lastCall?.at(-1)).toMatchObject({ explicitOnly: true });
expect(lastCall?.[1]).toEqual([]);
expect(readAutoMemoryIndex).not.toHaveBeenCalled();
expect(config.getUserMemory()).toContain('Project rules');
Expand Down Expand Up @@ -1468,7 +1492,49 @@ describe('Server Config (config.ts)', () => {

const lastCall = vi.mocked(loadServerHierarchicalMemory).mock.calls.at(-1);
expect(lastCall?.[1]).toEqual([explicitDir]);
expect(lastCall?.at(-1)).toEqual({ explicitOnly: true });
expect(lastCall?.at(-1)).toMatchObject({ explicitOnly: true });
});

it('refreshHierarchicalMemory should fire InstructionsLoaded hooks from memory notifications', async () => {
const config = new Config(baseParams);
const fireInstructionsLoadedEvent = vi.fn().mockResolvedValue(undefined);
config['hookSystem'] = {
fireInstructionsLoadedEvent,
} as unknown as HookSystem;

vi.mocked(loadServerHierarchicalMemory).mockResolvedValue({
memoryContent: '--- Context from: QWEN.md ---\nProject rules',
fileCount: 1,
ruleCount: 0,
conditionalRules: [],
projectRoot: '/tmp',
});

await config.refreshHierarchicalMemory();

const lastCall = vi.mocked(loadServerHierarchicalMemory).mock.calls.at(-1);
const options = lastCall?.at(-1) as
| LoadServerHierarchicalMemoryOptions
| undefined;
expect(options?.onInstructionsLoaded).toEqual(expect.any(Function));

await options?.onInstructionsLoaded?.({
filePath: '/tmp/project/QWEN.md',
memoryType: 'project',
loadReason: 'include',
triggerFilePath: '/tmp/project/AGENTS.md',
parentFilePath: '/tmp/project/AGENTS.md',
});

expect(fireInstructionsLoadedEvent).toHaveBeenCalledWith(
'/tmp/project/QWEN.md',
'project',
'include',
{
triggerFilePath: '/tmp/project/AGENTS.md',
parentFilePath: '/tmp/project/AGENTS.md',
},
);
});

it('Config constructor should call setGeminiMdFilename with contextFileName if provided', () => {
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ import {
ExtensionManager,
type Extension,
} from '../extension/extensionManager.js';
import { HookSystem, createHookOutput } from '../hooks/index.js';
import {
HookSystem,
createHookOutput,
createInstructionsLoadedCallback,
} from '../hooks/index.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import {
MessageBusType,
Expand Down Expand Up @@ -1864,7 +1868,12 @@ export class Config {
this.isTrustedFolder(),
this.getImportFormat(),
this.contextRuleExcludes,
{ explicitOnly: this.getBareMode() },
{
explicitOnly: this.getBareMode(),
onInstructionsLoaded: createInstructionsLoadedCallback(
() => this.hookSystem,
),
},
);
if (this.getManagedAutoMemoryEnabled()) {
const managedAutoMemoryIndex = await readAutoMemoryIndex(
Expand Down
46 changes: 46 additions & 0 deletions packages/core/src/hooks/hookEventHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,52 @@ describe('HookEventHandler', () => {
});
});

describe('fireInstructionsLoadedEvent', () => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This test passes parentFilePath but never triggerFilePath. The camelCase→snake_case mapping (options.triggerFilePath → input.trigger_file_path at hookEventHandler.ts:144) has zero test coverage. Consider adding a second test case that passes triggerFilePath and asserts input.trigger_file_path:

it('should include trigger_file_path when provided', async () => {
  // ...same setup...
  await hookEventHandler.fireInstructionsLoadedEvent(
    '/repo/QWEN.md', 'project', 'include',
    { triggerFilePath: '/repo/AGENTS.md', parentFilePath: '/repo/QWEN.md' },
  );
  const input = mockCalls[0][2] as InstructionsLoadedInput;
  expect(input.trigger_file_path).toBe('/repo/AGENTS.md');
  expect(input.parent_file_path).toBe('/repo/QWEN.md');
});

— qwen3.7-max via Qwen Code /review

it('should include instruction load metadata in hook input', async () => {
const mockPlan = createMockExecutionPlan([
{
type: HookType.Command,
command: 'echo test',
source: HooksConfigSource.Project,
},
]);
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
createMockAggregatedResult(true),
);

await hookEventHandler.fireInstructionsLoadedEvent(
'/repo/.qwen/QWEN.local.md',
'local',
'include',
{
parentFilePath: '/repo/QWEN.md',
},
);

expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
HookEventName.InstructionsLoaded,
{
filePath: '/repo/.qwen/QWEN.local.md',
},
);

const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
.calls;
const input = mockCalls[0][2] as {
file_path: string;
memory_type: string;
load_reason: string;
parent_file_path?: string;
};
expect(input.file_path).toBe('/repo/.qwen/QWEN.local.md');
expect(input.memory_type).toBe('local');
expect(input.load_reason).toBe('include');
expect(input.parent_file_path).toBe('/repo/QWEN.md');
});
});

describe('fireStopEvent', () => {
it('should execute hooks for Stop event', async () => {
const mockPlan = createMockExecutionPlan([]);
Expand Down
32 changes: 32 additions & 0 deletions packages/core/src/hooks/hookEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import type {
TodoCompletedInput,
TodoItem,
TodoStatus,
InstructionsLoadedInput,
InstructionMemoryType,
InstructionLoadReason,
} from './types.js';
import { HookPhase, PermissionMode } from './types.js';
import { createDebugLogger } from '../utils/debugLogger.js';
Expand Down Expand Up @@ -116,6 +119,35 @@ export class HookEventHandler {
);
}

async fireInstructionsLoadedEvent(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] fireInstructionsLoadedEvent is the only fire*Event method in HookEventHandler without a JSDoc comment. Every other public fire method has a /** Fire a ... event */ doc block.

Suggested change
async fireInstructionsLoadedEvent(
/**
* Fire an InstructionsLoaded event
* Called when instruction/context files are loaded during session startup or import resolution
*/
async fireInstructionsLoadedEvent(

— qwen3.7-max via Qwen Code /review

filePath: string,
memoryType: InstructionMemoryType,
loadReason: InstructionLoadReason,
options: {
triggerFilePath?: string;
parentFilePath?: string;
} = {},
signal?: AbortSignal,
): Promise<AggregatedHookResult> {
const input: InstructionsLoadedInput = {
...this.createBaseInput(HookEventName.InstructionsLoaded),
file_path: filePath,
memory_type: memoryType,
load_reason: loadReason,
trigger_file_path: options.triggerFilePath,
parent_file_path: options.parentFilePath,
};

return this.executeHooks(
HookEventName.InstructionsLoaded,
input,
{
filePath,
},
signal,
);
}

/**
* Fire a Stop event
* Called by handleHookExecutionRequest - executes hooks directly
Expand Down
Loading
Loading