Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
42 changes: 42 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1177,6 +1177,48 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2);
});

it('should concatenate hook definitions from user and workspace scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const hookRunning = (command: string) => [
{ hooks: [{ type: 'command', command }] },
];
const userSettings = {
hooks: {
PostCompact: hookRunning('user-post-compact'),
TodoCreated: hookRunning('user-todo-created'),
},
};
const workspaceSettings = {
hooks: {
PostCompact: hookRunning('workspace-post-compact'),
TodoCreated: hookRunning('workspace-todo-created'),
},
};

(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) return JSON.stringify(userSettings);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettings);
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);

// Both scopes run: a workspace definition does not replace the user's.
expect(settings.merged.hooks).toMatchObject({
PostCompact: [
{ hooks: [{ command: 'user-post-compact' }] },
{ hooks: [{ command: 'workspace-post-compact' }] },
],
TodoCreated: [
{ hooks: [{ command: 'user-todo-created' }] },
{ hooks: [{ command: 'workspace-todo-created' }] },
],
});
});

it('should UNION-merge slashCommands.disabled across user and workspace scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettings = {
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/config/settingsSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GOAL_MAX_ACTIVE_MINUTES_CAP,
GOAL_MAX_TURNS_CAP,
HELD_EXPIRY_OPTIONS,
HookEventName,
DEFAULT_SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH,
OutputFormat,
SENSITIVE_SPAN_ATTRIBUTE_MAX_LENGTH_LIMIT,
Expand Down Expand Up @@ -45,6 +46,30 @@ describe('SettingsSchema', () => {
expect(hookProperties?.['model']).toMatchObject({ type: 'string' });
});

it('should declare a hooks setting for every hook event', () => {
expect(
Object.keys(getSettingsSchema().hooks.properties ?? {}).sort(),
).toEqual([...Object.values(HookEventName)].sort());
});

it('should concatenate every hook event across settings scopes', () => {
const hookProperties = getSettingsSchema().hooks.properties ?? {};
const eventNames = Object.values(HookEventName);

expect(
Object.fromEntries(
eventNames.map((eventName) => [
eventName,
hookProperties[eventName]?.mergeStrategy,
]),
),
).toEqual(
Object.fromEntries(
eventNames.map((eventName) => [eventName, MergeStrategy.CONCAT]),
),
);
});

it('should contain all expected top-level settings', () => {
const expectedSettings: Array<keyof Settings> = [
'mcpServers',
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3900,6 +3900,66 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PostCompact: {
type: 'array',
label: 'Post Compact Hooks',
Comment thread
qqqys marked this conversation as resolved.
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute after conversation compaction completes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PermissionDenied: {
type: 'array',
label: 'Permission Denied Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when AUTO-mode classification denies a tool call.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
TodoCreated: {
type: 'array',
label: 'Todo Created Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a new todo item is created. They can block creation during validation.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
TodoCompleted: {
type: 'array',
label: 'Todo Completed Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a todo item is marked as completed. They can block completion during validation.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
InstructionsLoaded: {
type: 'array',
label: 'Instructions Loaded Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when an instruction file such as QWEN.md is loaded into context.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
},
},

Expand Down
Loading
Loading