Merge pull request #452 from kaitranntt/kai/fix/450-hook-duplication

fix(hooks): deduplicate WebSearch hooks when saving via Dashboard
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-04 12:06:05 -05:00
committed by GitHub
4 changed files with 95 additions and 2 deletions
+7 -1
View File
@@ -48,5 +48,11 @@ export function deduplicateCcsHooks(settings: Record<string, unknown>): boolean
return false; // Remove subsequent duplicates
});
return hooks.PreToolUse.length < originalLength;
const newLength = hooks.PreToolUse.length;
if (process.env.CCS_DEBUG && newLength < originalLength) {
const removedCount = originalLength - newLength;
console.error(`Removed ${removedCount} duplicate CCS WebSearch hook(s)`);
}
return newLength < originalLength;
}
+5
View File
@@ -18,6 +18,7 @@ import {
resetAuthToDefaults,
} from '../../cliproxy';
import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import type { Settings } from '../../types/config';
const router = Router();
@@ -136,6 +137,10 @@ router.put('/:profile', (req: Request, res: Response): void => {
return;
}
// Deduplicate CCS hooks to prevent accumulation (fixes #450)
// This handles cases where duplicate hooks were added by previous versions
deduplicateCcsHooks(settings as Record<string, unknown>);
const ccsDir = getCcsDir();
// Check for missing required fields (warning, not blocking - runtime fills defaults)
+4 -1
View File
@@ -4,7 +4,10 @@
* Tests for exponential backoff retry behavior on 429 rate limit errors
*/
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
import { describe, it, expect, beforeEach, afterEach, setDefaultTimeout } from 'bun:test';
// Increase timeout for CI - dynamic imports and proxy creation are slow on CI runners
setDefaultTimeout(30000);
// Store original env vars
const originalEnv = { ...process.env };
@@ -289,4 +289,83 @@ describe('deduplicateCcsHooks', () => {
expect(result).toBe(false);
expect(settings.hooks.PreToolUse).toHaveLength(0);
});
test('Stress test: 15 duplicate CCS WebSearch hooks', () => {
const ccsHook = {
matcher: 'WebSearch',
hooks: [
{
command: 'node /home/user/.ccs/hooks/websearch-transformer/index.js',
},
],
};
const settings = {
hooks: {
PreToolUse: Array(15).fill(ccsHook),
},
};
const result = deduplicateCcsHooks(settings);
expect(result).toBe(true);
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(settings.hooks.PreToolUse[0]).toEqual(ccsHook);
});
test('Leaves PostToolUse and PreToolCall untouched', () => {
const postToolUseHook1 = {
matcher: 'CustomMatcher1',
hooks: [{ command: 'custom-command-1' }],
};
const postToolUseHook2 = {
matcher: 'CustomMatcher2',
hooks: [{ command: 'custom-command-2' }],
};
const preToolCallHook = {
matcher: 'CustomMatcher3',
hooks: [{ command: 'custom-command-3' }],
};
const settings = {
hooks: {
PreToolUse: [
{
matcher: 'WebSearch',
hooks: [
{
command: 'node /path1/.ccs/hooks/websearch-transformer/index.js',
},
],
},
{
matcher: 'WebSearch',
hooks: [
{
command: 'node /path2/.ccs/hooks/websearch-transformer/index.js',
},
],
},
{
matcher: 'WebSearch',
hooks: [
{
command: 'node /path3/.ccs/hooks/websearch-transformer/index.js',
},
],
},
],
PostToolUse: [postToolUseHook1, postToolUseHook2],
PreToolCall: [preToolCallHook],
},
};
const result = deduplicateCcsHooks(settings);
expect(result).toBe(true);
// PreToolUse should be deduplicated to 1 hook
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(settings.hooks.PreToolUse[0].matcher).toBe('WebSearch');
// PostToolUse should remain unchanged with 2 hooks
expect(settings.hooks.PostToolUse).toHaveLength(2);
expect(settings.hooks.PostToolUse[0]).toEqual(postToolUseHook1);
expect(settings.hooks.PostToolUse[1]).toEqual(postToolUseHook2);
// PreToolCall should remain unchanged with 1 hook
expect(settings.hooks.PreToolCall).toHaveLength(1);
expect(settings.hooks.PreToolCall[0]).toEqual(preToolCallHook);
});
});