@librechat/agents 3.1.67-dev.4 → 3.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/dist/cjs/agents/AgentContext.cjs +3 -23
  2. package/dist/cjs/agents/AgentContext.cjs.map +1 -1
  3. package/dist/cjs/common/enum.cjs +0 -16
  4. package/dist/cjs/common/enum.cjs.map +1 -1
  5. package/dist/cjs/graphs/Graph.cjs +0 -91
  6. package/dist/cjs/graphs/Graph.cjs.map +1 -1
  7. package/dist/cjs/graphs/MultiAgentGraph.cjs +36 -0
  8. package/dist/cjs/graphs/MultiAgentGraph.cjs.map +1 -1
  9. package/dist/cjs/main.cjs +1 -53
  10. package/dist/cjs/main.cjs.map +1 -1
  11. package/dist/cjs/messages/format.cjs +12 -74
  12. package/dist/cjs/messages/format.cjs.map +1 -1
  13. package/dist/cjs/run.cjs +0 -111
  14. package/dist/cjs/run.cjs.map +1 -1
  15. package/dist/cjs/summarization/index.cjs +41 -0
  16. package/dist/cjs/summarization/index.cjs.map +1 -1
  17. package/dist/cjs/summarization/node.cjs +121 -63
  18. package/dist/cjs/summarization/node.cjs.map +1 -1
  19. package/dist/cjs/tools/ToolNode.cjs +140 -304
  20. package/dist/cjs/tools/ToolNode.cjs.map +1 -1
  21. package/dist/esm/agents/AgentContext.mjs +3 -23
  22. package/dist/esm/agents/AgentContext.mjs.map +1 -1
  23. package/dist/esm/common/enum.mjs +1 -15
  24. package/dist/esm/common/enum.mjs.map +1 -1
  25. package/dist/esm/graphs/Graph.mjs +0 -91
  26. package/dist/esm/graphs/Graph.mjs.map +1 -1
  27. package/dist/esm/graphs/MultiAgentGraph.mjs +36 -0
  28. package/dist/esm/graphs/MultiAgentGraph.mjs.map +1 -1
  29. package/dist/esm/main.mjs +2 -13
  30. package/dist/esm/main.mjs.map +1 -1
  31. package/dist/esm/messages/format.mjs +4 -66
  32. package/dist/esm/messages/format.mjs.map +1 -1
  33. package/dist/esm/run.mjs +0 -111
  34. package/dist/esm/run.mjs.map +1 -1
  35. package/dist/esm/summarization/index.mjs +41 -1
  36. package/dist/esm/summarization/index.mjs.map +1 -1
  37. package/dist/esm/summarization/node.mjs +121 -63
  38. package/dist/esm/summarization/node.mjs.map +1 -1
  39. package/dist/esm/tools/ToolNode.mjs +142 -306
  40. package/dist/esm/tools/ToolNode.mjs.map +1 -1
  41. package/dist/types/agents/AgentContext.d.ts +0 -6
  42. package/dist/types/common/enum.d.ts +1 -10
  43. package/dist/types/graphs/Graph.d.ts +0 -2
  44. package/dist/types/graphs/MultiAgentGraph.d.ts +12 -0
  45. package/dist/types/index.d.ts +0 -8
  46. package/dist/types/messages/format.d.ts +1 -2
  47. package/dist/types/run.d.ts +0 -1
  48. package/dist/types/summarization/index.d.ts +2 -0
  49. package/dist/types/summarization/node.d.ts +0 -2
  50. package/dist/types/tools/ToolNode.d.ts +2 -24
  51. package/dist/types/types/graph.d.ts +2 -61
  52. package/dist/types/types/index.d.ts +0 -1
  53. package/dist/types/types/run.d.ts +0 -20
  54. package/dist/types/types/tools.d.ts +1 -38
  55. package/package.json +1 -5
  56. package/src/agents/AgentContext.ts +2 -26
  57. package/src/common/enum.ts +0 -15
  58. package/src/graphs/Graph.ts +0 -113
  59. package/src/graphs/MultiAgentGraph.ts +39 -0
  60. package/src/graphs/__tests__/MultiAgentGraph.test.ts +91 -0
  61. package/src/index.ts +0 -10
  62. package/src/messages/format.ts +4 -74
  63. package/src/run.ts +0 -126
  64. package/src/summarization/__tests__/node.test.ts +42 -0
  65. package/src/summarization/__tests__/trigger.test.ts +100 -1
  66. package/src/summarization/index.ts +47 -0
  67. package/src/summarization/node.ts +149 -77
  68. package/src/tools/ToolNode.ts +169 -391
  69. package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
  70. package/src/types/graph.ts +1 -80
  71. package/src/types/index.ts +0 -1
  72. package/src/types/run.ts +0 -20
  73. package/src/types/tools.ts +1 -41
  74. package/dist/cjs/hooks/HookRegistry.cjs +0 -162
  75. package/dist/cjs/hooks/HookRegistry.cjs.map +0 -1
  76. package/dist/cjs/hooks/executeHooks.cjs +0 -276
  77. package/dist/cjs/hooks/executeHooks.cjs.map +0 -1
  78. package/dist/cjs/hooks/matchers.cjs +0 -256
  79. package/dist/cjs/hooks/matchers.cjs.map +0 -1
  80. package/dist/cjs/hooks/types.cjs +0 -27
  81. package/dist/cjs/hooks/types.cjs.map +0 -1
  82. package/dist/cjs/tools/BashExecutor.cjs +0 -175
  83. package/dist/cjs/tools/BashExecutor.cjs.map +0 -1
  84. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +0 -296
  85. package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +0 -1
  86. package/dist/cjs/tools/ReadFile.cjs +0 -43
  87. package/dist/cjs/tools/ReadFile.cjs.map +0 -1
  88. package/dist/cjs/tools/SkillTool.cjs +0 -50
  89. package/dist/cjs/tools/SkillTool.cjs.map +0 -1
  90. package/dist/cjs/tools/SubagentTool.cjs +0 -92
  91. package/dist/cjs/tools/SubagentTool.cjs.map +0 -1
  92. package/dist/cjs/tools/skillCatalog.cjs +0 -84
  93. package/dist/cjs/tools/skillCatalog.cjs.map +0 -1
  94. package/dist/cjs/tools/subagent/SubagentExecutor.cjs +0 -511
  95. package/dist/cjs/tools/subagent/SubagentExecutor.cjs.map +0 -1
  96. package/dist/esm/hooks/HookRegistry.mjs +0 -160
  97. package/dist/esm/hooks/HookRegistry.mjs.map +0 -1
  98. package/dist/esm/hooks/executeHooks.mjs +0 -273
  99. package/dist/esm/hooks/executeHooks.mjs.map +0 -1
  100. package/dist/esm/hooks/matchers.mjs +0 -251
  101. package/dist/esm/hooks/matchers.mjs.map +0 -1
  102. package/dist/esm/hooks/types.mjs +0 -25
  103. package/dist/esm/hooks/types.mjs.map +0 -1
  104. package/dist/esm/tools/BashExecutor.mjs +0 -169
  105. package/dist/esm/tools/BashExecutor.mjs.map +0 -1
  106. package/dist/esm/tools/BashProgrammaticToolCalling.mjs +0 -287
  107. package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +0 -1
  108. package/dist/esm/tools/ReadFile.mjs +0 -38
  109. package/dist/esm/tools/ReadFile.mjs.map +0 -1
  110. package/dist/esm/tools/SkillTool.mjs +0 -45
  111. package/dist/esm/tools/SkillTool.mjs.map +0 -1
  112. package/dist/esm/tools/SubagentTool.mjs +0 -85
  113. package/dist/esm/tools/SubagentTool.mjs.map +0 -1
  114. package/dist/esm/tools/skillCatalog.mjs +0 -82
  115. package/dist/esm/tools/skillCatalog.mjs.map +0 -1
  116. package/dist/esm/tools/subagent/SubagentExecutor.mjs +0 -505
  117. package/dist/esm/tools/subagent/SubagentExecutor.mjs.map +0 -1
  118. package/dist/types/hooks/HookRegistry.d.ts +0 -56
  119. package/dist/types/hooks/executeHooks.d.ts +0 -79
  120. package/dist/types/hooks/index.d.ts +0 -6
  121. package/dist/types/hooks/matchers.d.ts +0 -95
  122. package/dist/types/hooks/types.d.ts +0 -320
  123. package/dist/types/tools/BashExecutor.d.ts +0 -45
  124. package/dist/types/tools/BashProgrammaticToolCalling.d.ts +0 -72
  125. package/dist/types/tools/ReadFile.d.ts +0 -28
  126. package/dist/types/tools/SkillTool.d.ts +0 -40
  127. package/dist/types/tools/SubagentTool.d.ts +0 -36
  128. package/dist/types/tools/skillCatalog.d.ts +0 -19
  129. package/dist/types/tools/subagent/SubagentExecutor.d.ts +0 -137
  130. package/dist/types/tools/subagent/index.d.ts +0 -2
  131. package/dist/types/types/skill.d.ts +0 -9
  132. package/src/hooks/HookRegistry.ts +0 -208
  133. package/src/hooks/__tests__/HookRegistry.test.ts +0 -190
  134. package/src/hooks/__tests__/compactHooks.test.ts +0 -214
  135. package/src/hooks/__tests__/executeHooks.test.ts +0 -1013
  136. package/src/hooks/__tests__/integration.test.ts +0 -337
  137. package/src/hooks/__tests__/matchers.test.ts +0 -238
  138. package/src/hooks/__tests__/toolHooks.test.ts +0 -669
  139. package/src/hooks/executeHooks.ts +0 -375
  140. package/src/hooks/index.ts +0 -57
  141. package/src/hooks/matchers.ts +0 -280
  142. package/src/hooks/types.ts +0 -404
  143. package/src/messages/formatAgentMessages.skills.test.ts +0 -334
  144. package/src/scripts/multi-agent-subagent.ts +0 -246
  145. package/src/scripts/subagent-event-driven-debug.ts +0 -190
  146. package/src/scripts/subagent-tools-debug.ts +0 -160
  147. package/src/specs/subagent.test.ts +0 -305
  148. package/src/tools/BashExecutor.ts +0 -205
  149. package/src/tools/BashProgrammaticToolCalling.ts +0 -397
  150. package/src/tools/ReadFile.ts +0 -39
  151. package/src/tools/SkillTool.ts +0 -46
  152. package/src/tools/SubagentTool.ts +0 -100
  153. package/src/tools/__tests__/ReadFile.test.ts +0 -44
  154. package/src/tools/__tests__/SkillTool.test.ts +0 -442
  155. package/src/tools/__tests__/SubagentExecutor.test.ts +0 -1148
  156. package/src/tools/__tests__/SubagentTool.test.ts +0 -149
  157. package/src/tools/__tests__/skillCatalog.test.ts +0 -161
  158. package/src/tools/__tests__/subagentHooks.test.ts +0 -215
  159. package/src/tools/skillCatalog.ts +0 -126
  160. package/src/tools/subagent/SubagentExecutor.ts +0 -676
  161. package/src/tools/subagent/index.ts +0 -13
  162. package/src/types/skill.ts +0 -11
@@ -1,337 +0,0 @@
1
- // src/hooks/__tests__/integration.test.ts
2
- import { HumanMessage } from '@langchain/core/messages';
3
- import { HookRegistry } from '../HookRegistry';
4
- import { Run } from '@/run';
5
- import type * as t from '@/types';
6
- import type {
7
- HookCallback,
8
- RunStartHookInput,
9
- RunStartHookOutput,
10
- UserPromptSubmitHookOutput,
11
- StopHookInput,
12
- StopHookOutput,
13
- StopFailureHookOutput,
14
- } from '../types';
15
- import { Providers } from '@/common';
16
-
17
- const llmConfig: t.LLMConfig = {
18
- provider: Providers.OPENAI,
19
- streaming: true,
20
- streamUsage: false,
21
- };
22
-
23
- const callerConfig = {
24
- configurable: { thread_id: 'test-thread' },
25
- streamMode: 'values' as const,
26
- version: 'v2' as const,
27
- };
28
-
29
- function createRun(
30
- hooks: HookRegistry,
31
- runId = 'test-run'
32
- ): Promise<Run<t.IState>> {
33
- return Run.create<t.IState>({
34
- runId,
35
- graphConfig: { type: 'standard', llmConfig },
36
- returnContent: true,
37
- skipCleanup: true,
38
- hooks,
39
- });
40
- }
41
-
42
- describe('Run-level hook integration', () => {
43
- jest.setTimeout(15000);
44
-
45
- describe('RunStart', () => {
46
- it('fires with runId, threadId, and messages before the stream', async () => {
47
- const registry = new HookRegistry();
48
- let captured: RunStartHookInput | undefined;
49
- const hook: HookCallback<'RunStart'> = async (
50
- input
51
- ): Promise<RunStartHookOutput> => {
52
- captured = input;
53
- return {};
54
- };
55
- registry.register('RunStart', { hooks: [hook] });
56
-
57
- const run = await createRun(registry);
58
- run.Graph!.overrideTestModel(['hello']);
59
- const inputs = { messages: [new HumanMessage('hi')] };
60
- await run.processStream(inputs, callerConfig);
61
-
62
- expect(captured).toBeDefined();
63
- expect(captured!.hook_event_name).toBe('RunStart');
64
- expect(captured!.runId).toBe('test-run');
65
- expect(captured!.threadId).toBe('test-thread');
66
- expect(captured!.messages).toHaveLength(1);
67
- });
68
- });
69
-
70
- describe('UserPromptSubmit', () => {
71
- it('extracts prompt text from the last human message', async () => {
72
- const registry = new HookRegistry();
73
- let capturedPrompt = '';
74
- const hook: HookCallback<'UserPromptSubmit'> = async (
75
- input
76
- ): Promise<UserPromptSubmitHookOutput> => {
77
- capturedPrompt = input.prompt;
78
- return {};
79
- };
80
- registry.register('UserPromptSubmit', { hooks: [hook] });
81
-
82
- const run = await createRun(registry);
83
- run.Graph!.overrideTestModel(['response']);
84
- const inputs = { messages: [new HumanMessage('hello world')] };
85
- await run.processStream(inputs, callerConfig);
86
-
87
- expect(capturedPrompt).toBe('hello world');
88
- });
89
-
90
- it('extracts prompt from multi-part content (text + non-text blocks)', async () => {
91
- const registry = new HookRegistry();
92
- let capturedPrompt = '';
93
- const hook: HookCallback<'UserPromptSubmit'> = async (
94
- input
95
- ): Promise<UserPromptSubmitHookOutput> => {
96
- capturedPrompt = input.prompt;
97
- return {};
98
- };
99
- registry.register('UserPromptSubmit', { hooks: [hook] });
100
-
101
- const run = await createRun(registry);
102
- run.Graph!.overrideTestModel(['ok']);
103
- const msg = new HumanMessage({
104
- content: [
105
- { type: 'text', text: 'hello' },
106
- {
107
- type: 'image_url',
108
- image_url: { url: 'data:image/png;base64,...' },
109
- },
110
- { type: 'text', text: 'world' },
111
- ],
112
- });
113
- await run.processStream({ messages: [msg] }, callerConfig);
114
-
115
- expect(capturedPrompt).toBe('hello\nworld');
116
- });
117
-
118
- it('yields empty prompt for image-only content', async () => {
119
- const registry = new HookRegistry();
120
- let capturedPrompt: string | undefined;
121
- const hook: HookCallback<'UserPromptSubmit'> = async (
122
- input
123
- ): Promise<UserPromptSubmitHookOutput> => {
124
- capturedPrompt = input.prompt;
125
- return {};
126
- };
127
- registry.register('UserPromptSubmit', { hooks: [hook] });
128
-
129
- const run = await createRun(registry);
130
- run.Graph!.overrideTestModel(['ok']);
131
- const msg = new HumanMessage({
132
- content: [
133
- {
134
- type: 'image_url',
135
- image_url: { url: 'data:image/png;base64,...' },
136
- },
137
- ],
138
- });
139
- await run.processStream({ messages: [msg] }, callerConfig);
140
-
141
- expect(capturedPrompt).toBe('');
142
- });
143
-
144
- it('fires with empty prompt when human message has no text blocks', async () => {
145
- const registry = new HookRegistry();
146
- let capturedPrompt: string | undefined;
147
- const hook: HookCallback<'UserPromptSubmit'> = async (
148
- input
149
- ): Promise<UserPromptSubmitHookOutput> => {
150
- capturedPrompt = input.prompt;
151
- return {};
152
- };
153
- registry.register('UserPromptSubmit', { hooks: [hook] });
154
-
155
- const run = await createRun(registry);
156
- run.Graph!.overrideTestModel(['ok']);
157
- const msg = new HumanMessage({ content: [] });
158
- await run.processStream({ messages: [msg] }, callerConfig);
159
-
160
- expect(capturedPrompt).toBe('');
161
- });
162
-
163
- it('aborts the run when hook returns deny', async () => {
164
- const registry = new HookRegistry();
165
- let stopFired = false;
166
- const denyHook: HookCallback<
167
- 'UserPromptSubmit'
168
- > = async (): Promise<UserPromptSubmitHookOutput> => ({
169
- decision: 'deny',
170
- reason: 'blocked by policy',
171
- });
172
- const stopHook: HookCallback<
173
- 'Stop'
174
- > = async (): Promise<StopHookOutput> => {
175
- stopFired = true;
176
- return {};
177
- };
178
- registry.register('UserPromptSubmit', { hooks: [denyHook] });
179
- registry.register('Stop', { hooks: [stopHook] });
180
-
181
- const run = await createRun(registry);
182
- run.Graph!.overrideTestModel(['should not reach']);
183
- const inputs = { messages: [new HumanMessage('hi')] };
184
- const result = await run.processStream(inputs, callerConfig);
185
-
186
- expect(result).toBeUndefined();
187
- expect(stopFired).toBe(false);
188
- });
189
-
190
- it('aborts the run when hook returns ask (v1 — no interactive flow)', async () => {
191
- const registry = new HookRegistry();
192
- const askHook: HookCallback<
193
- 'UserPromptSubmit'
194
- > = async (): Promise<UserPromptSubmitHookOutput> => ({
195
- decision: 'ask',
196
- reason: 'needs confirmation',
197
- });
198
- registry.register('UserPromptSubmit', { hooks: [askHook] });
199
-
200
- const run = await createRun(registry);
201
- run.Graph!.overrideTestModel(['should not reach']);
202
- const inputs = { messages: [new HumanMessage('hi')] };
203
- const result = await run.processStream(inputs, callerConfig);
204
-
205
- expect(result).toBeUndefined();
206
- });
207
- });
208
-
209
- describe('Stop', () => {
210
- it('fires after a successful stream with accumulated messages', async () => {
211
- const registry = new HookRegistry();
212
- let captured: StopHookInput | undefined;
213
- const hook: HookCallback<'Stop'> = async (
214
- input
215
- ): Promise<StopHookOutput> => {
216
- captured = input;
217
- return {};
218
- };
219
- registry.register('Stop', { hooks: [hook] });
220
-
221
- const run = await createRun(registry);
222
- run.Graph!.overrideTestModel(['agent reply']);
223
- const inputs = { messages: [new HumanMessage('hi')] };
224
- await run.processStream(inputs, callerConfig);
225
-
226
- expect(captured).toBeDefined();
227
- expect(captured!.hook_event_name).toBe('Stop');
228
- expect(captured!.runId).toBe('test-run');
229
- expect(captured!.stopHookActive).toBe(false);
230
- expect(captured!.messages.length).toBeGreaterThanOrEqual(1);
231
- });
232
-
233
- it('does not fire when the stream throws an error', async () => {
234
- const registry = new HookRegistry();
235
- let stopFired = false;
236
- const hook: HookCallback<'Stop'> = async (): Promise<StopHookOutput> => {
237
- stopFired = true;
238
- return {};
239
- };
240
- registry.register('Stop', { hooks: [hook] });
241
-
242
- const run = await createRun(registry, 'error-run');
243
- run.Graph!.overrideTestModel([]);
244
-
245
- const inputs = { messages: [new HumanMessage('hi')] };
246
- try {
247
- await run.processStream(inputs, callerConfig);
248
- } catch {
249
- /* expected */
250
- }
251
-
252
- expect(stopFired).toBe(false);
253
- });
254
- });
255
-
256
- describe('StopFailure', () => {
257
- it('fires when the stream throws and preserves the original error', async () => {
258
- const registry = new HookRegistry();
259
- let capturedError = '';
260
- const hook: HookCallback<'StopFailure'> = async (
261
- input
262
- ): Promise<StopFailureHookOutput> => {
263
- capturedError = input.error;
264
- return {};
265
- };
266
- registry.register('StopFailure', { hooks: [hook] });
267
-
268
- const run = await createRun(registry, 'fail-run');
269
- run.Graph!.overrideTestModel([]);
270
-
271
- const inputs = { messages: [new HumanMessage('hi')] };
272
- let thrownError: Error | undefined;
273
- try {
274
- await run.processStream(inputs, callerConfig);
275
- } catch (err) {
276
- thrownError = err instanceof Error ? err : new Error(String(err));
277
- }
278
-
279
- expect(thrownError).toBeDefined();
280
- expect(typeof capturedError).toBe('string');
281
- expect(capturedError.length).toBeGreaterThan(0);
282
- });
283
- });
284
-
285
- describe('session teardown', () => {
286
- it('clears session matchers after processStream completes', async () => {
287
- const registry = new HookRegistry();
288
- registry.registerSession('test-run', 'RunStart', {
289
- hooks: [async (): Promise<RunStartHookOutput> => ({})],
290
- });
291
- expect(registry.getMatchers('RunStart', 'test-run')).toHaveLength(1);
292
-
293
- const run = await createRun(registry);
294
- run.Graph!.overrideTestModel(['done']);
295
- const inputs = { messages: [new HumanMessage('hi')] };
296
- await run.processStream(inputs, callerConfig);
297
-
298
- expect(registry.getMatchers('RunStart', 'test-run')).toHaveLength(0);
299
- });
300
-
301
- it('clears session even when the stream errors', async () => {
302
- const registry = new HookRegistry();
303
- registry.registerSession('error-run', 'RunStart', {
304
- hooks: [async (): Promise<RunStartHookOutput> => ({})],
305
- });
306
-
307
- const run = await createRun(registry, 'error-run');
308
- run.Graph!.overrideTestModel([]);
309
-
310
- const inputs = { messages: [new HumanMessage('hi')] };
311
- try {
312
- await run.processStream(inputs, callerConfig);
313
- } catch {
314
- /* expected */
315
- }
316
-
317
- expect(registry.getMatchers('RunStart', 'error-run')).toHaveLength(0);
318
- });
319
- });
320
-
321
- describe('no-hooks baseline', () => {
322
- it('works identically when no hooks registry is provided', async () => {
323
- const run = await Run.create<t.IState>({
324
- runId: 'no-hooks-run',
325
- graphConfig: { type: 'standard', llmConfig },
326
- returnContent: true,
327
- skipCleanup: true,
328
- });
329
- run.Graph!.overrideTestModel(['response']);
330
- const inputs = { messages: [new HumanMessage('hi')] };
331
- const result = await run.processStream(inputs, callerConfig);
332
-
333
- expect(result).toBeDefined();
334
- expect(result!.length).toBeGreaterThan(0);
335
- });
336
- });
337
- });
@@ -1,238 +0,0 @@
1
- // src/hooks/__tests__/matchers.test.ts
2
- import {
3
- matchesQuery,
4
- clearMatcherCache,
5
- getMatcherCacheSize,
6
- hasNestedQuantifier,
7
- MAX_PATTERN_LENGTH,
8
- MAX_CACHE_SIZE,
9
- } from '../matchers';
10
-
11
- describe('matchesQuery', () => {
12
- beforeEach(() => {
13
- clearMatcherCache();
14
- });
15
-
16
- it('treats undefined pattern as a wildcard match', () => {
17
- expect(matchesQuery(undefined, 'Bash')).toBe(true);
18
- expect(matchesQuery(undefined, '')).toBe(true);
19
- expect(matchesQuery(undefined, undefined)).toBe(true);
20
- });
21
-
22
- it('treats empty-string pattern as a wildcard match', () => {
23
- expect(matchesQuery('', 'Bash')).toBe(true);
24
- expect(matchesQuery('', undefined)).toBe(true);
25
- });
26
-
27
- it('returns false when the pattern is set but the query is absent', () => {
28
- expect(matchesQuery('Bash', undefined)).toBe(false);
29
- expect(matchesQuery('Bash', '')).toBe(false);
30
- });
31
-
32
- it('runs the pattern as a regex against the query', () => {
33
- expect(matchesQuery('Bash', 'Bash')).toBe(true);
34
- expect(matchesQuery('^Bash$', 'Bash')).toBe(true);
35
- expect(matchesQuery('^Bash$', 'BashExtra')).toBe(false);
36
- expect(matchesQuery('Bash|Shell', 'Shell')).toBe(true);
37
- expect(matchesQuery('mcp_.*_search', 'mcp_github_search')).toBe(true);
38
- });
39
-
40
- it('does not throw on invalid regex and returns false instead', () => {
41
- expect(() => matchesQuery('[unclosed', 'anything')).not.toThrow();
42
- expect(matchesQuery('[unclosed', 'anything')).toBe(false);
43
- });
44
-
45
- describe('pattern length bound', () => {
46
- it('rejects patterns longer than MAX_PATTERN_LENGTH', () => {
47
- const tooLong = 'a'.repeat(MAX_PATTERN_LENGTH + 1);
48
- expect(matchesQuery(tooLong, 'aaa')).toBe(false);
49
- });
50
-
51
- it('accepts patterns exactly at MAX_PATTERN_LENGTH', () => {
52
- const atLimit = 'a'.repeat(MAX_PATTERN_LENGTH);
53
- expect(matchesQuery(atLimit, 'a'.repeat(MAX_PATTERN_LENGTH))).toBe(true);
54
- });
55
- });
56
-
57
- describe('compilation cache', () => {
58
- it('caches successful compiles so the same RegExp object is reused', () => {
59
- const spy = jest.spyOn(global, 'RegExp');
60
- try {
61
- matchesQuery('^Bash$', 'Bash');
62
- matchesQuery('^Bash$', 'Edit');
63
- matchesQuery('^Bash$', 'Bash');
64
- expect(spy).toHaveBeenCalledTimes(1);
65
- } finally {
66
- spy.mockRestore();
67
- }
68
- });
69
-
70
- it('caches failed compiles so invalid patterns do not re-enter the compiler', () => {
71
- const spy = jest.spyOn(global, 'RegExp');
72
- try {
73
- matchesQuery('[unclosed', 'any');
74
- matchesQuery('[unclosed', 'any');
75
- matchesQuery('[unclosed', 'other');
76
- expect(spy).toHaveBeenCalledTimes(1);
77
- } finally {
78
- spy.mockRestore();
79
- }
80
- });
81
-
82
- it('clearMatcherCache drops cached compiles', () => {
83
- matchesQuery('^Bash$', 'Bash');
84
- clearMatcherCache();
85
- const spy = jest.spyOn(global, 'RegExp');
86
- try {
87
- matchesQuery('^Bash$', 'Bash');
88
- expect(spy).toHaveBeenCalledTimes(1);
89
- } finally {
90
- spy.mockRestore();
91
- }
92
- });
93
-
94
- it('evicts the oldest entry once the cache is full (LRU)', () => {
95
- for (let i = 0; i < MAX_CACHE_SIZE; i++) {
96
- matchesQuery(`^pattern${i}$`, `pattern${i}`);
97
- }
98
- expect(getMatcherCacheSize()).toBe(MAX_CACHE_SIZE);
99
-
100
- matchesQuery('^overflow$', 'overflow');
101
- expect(getMatcherCacheSize()).toBe(MAX_CACHE_SIZE);
102
-
103
- const spy = jest.spyOn(global, 'RegExp');
104
- try {
105
- matchesQuery('^pattern0$', 'pattern0');
106
- expect(spy).toHaveBeenCalledTimes(1);
107
- } finally {
108
- spy.mockRestore();
109
- }
110
- });
111
-
112
- it('refreshes LRU position on hit so hot patterns are not evicted', () => {
113
- const hotPattern = '^hot$';
114
- matchesQuery(hotPattern, 'hot');
115
- for (let i = 0; i < MAX_CACHE_SIZE - 1; i++) {
116
- matchesQuery(`^cold${i}$`, `cold${i}`);
117
- }
118
- matchesQuery(hotPattern, 'hot');
119
-
120
- matchesQuery('^overflow$', 'overflow');
121
-
122
- const spy = jest.spyOn(global, 'RegExp');
123
- try {
124
- matchesQuery(hotPattern, 'hot');
125
- expect(spy).not.toHaveBeenCalled();
126
- } finally {
127
- spy.mockRestore();
128
- }
129
- });
130
- });
131
-
132
- describe('hasNestedQuantifier', () => {
133
- it('detects the classic (a+)+ shape', () => {
134
- expect(hasNestedQuantifier('(a+)+')).toBe(true);
135
- expect(hasNestedQuantifier('(a+)+$')).toBe(true);
136
- });
137
-
138
- it('detects (.*)* and (.+)+', () => {
139
- expect(hasNestedQuantifier('(.*)*')).toBe(true);
140
- expect(hasNestedQuantifier('(.+)+')).toBe(true);
141
- });
142
-
143
- it('detects nested quantifier with ? outside', () => {
144
- expect(hasNestedQuantifier('(a+)?')).toBe(true);
145
- });
146
-
147
- it('detects nested quantifier with {n,} outside', () => {
148
- expect(hasNestedQuantifier('(a+){2,}')).toBe(true);
149
- });
150
-
151
- it('detects nested quantifier inside deeper groups', () => {
152
- expect(hasNestedQuantifier('((a+)+)')).toBe(true);
153
- expect(hasNestedQuantifier('prefix(\\w+)+suffix')).toBe(true);
154
- });
155
-
156
- it('allows quantifiers that are not nested', () => {
157
- expect(hasNestedQuantifier('a+')).toBe(false);
158
- expect(hasNestedQuantifier('^Bash$')).toBe(false);
159
- expect(hasNestedQuantifier('(a)(b)')).toBe(false);
160
- expect(hasNestedQuantifier('(a)+(b)')).toBe(false);
161
- expect(hasNestedQuantifier('(ab)+')).toBe(false);
162
- expect(hasNestedQuantifier('mcp_\\w+_search')).toBe(false);
163
- });
164
-
165
- it('ignores quantifier-looking chars inside character classes', () => {
166
- expect(hasNestedQuantifier('([a+b])+')).toBe(false);
167
- expect(hasNestedQuantifier('[*+?]+')).toBe(false);
168
- });
169
-
170
- it('ignores escaped quantifier characters', () => {
171
- expect(hasNestedQuantifier('(\\+)+')).toBe(false);
172
- expect(hasNestedQuantifier('(a\\*)+')).toBe(false);
173
- });
174
-
175
- describe('group-syntax prefixes are not misread as quantifiers', () => {
176
- it('allows non-capturing groups with optional quantifier', () => {
177
- expect(hasNestedQuantifier('(?:pre_)?tool_name')).toBe(false);
178
- expect(hasNestedQuantifier('(?:ab)?')).toBe(false);
179
- });
180
-
181
- it('allows non-capturing groups with + or * quantifier', () => {
182
- expect(hasNestedQuantifier('(?:Bash|Shell)+')).toBe(false);
183
- expect(hasNestedQuantifier('(?:ab)*')).toBe(false);
184
- expect(hasNestedQuantifier('(?:ab){2,5}')).toBe(false);
185
- });
186
-
187
- it('allows lookahead and negative lookahead', () => {
188
- expect(hasNestedQuantifier('(?=foo)bar')).toBe(false);
189
- expect(hasNestedQuantifier('(?!foo)bar')).toBe(false);
190
- expect(hasNestedQuantifier('(?=\\w+)bar')).toBe(false);
191
- });
192
-
193
- it('allows lookbehind and negative lookbehind', () => {
194
- expect(hasNestedQuantifier('(?<=\\s)\\w+')).toBe(false);
195
- expect(hasNestedQuantifier('(?<!^)\\w+')).toBe(false);
196
- });
197
-
198
- it('allows named capture groups with trailing quantifier', () => {
199
- expect(hasNestedQuantifier('(?<name>\\d+)')).toBe(false);
200
- expect(hasNestedQuantifier('(?<digits>\\d)+')).toBe(false);
201
- });
202
- });
203
-
204
- describe('risk propagation across non-capturing wrappers', () => {
205
- it('flags (?:(a+))+ — outer quantifier over a wrapped quantified group', () => {
206
- expect(hasNestedQuantifier('(?:(a+))+')).toBe(true);
207
- });
208
-
209
- it('flags (?:a+)+ — non-capturing group with internal quantifier', () => {
210
- expect(hasNestedQuantifier('(?:a+)+')).toBe(true);
211
- });
212
-
213
- it('does not flag (?:(ab))+ — quantified wrapper, no inner quantifier', () => {
214
- expect(hasNestedQuantifier('(?:(ab))+')).toBe(false);
215
- });
216
-
217
- it('flags ((ab)+)+ — multiply-wrapped but contains quantified subgroup', () => {
218
- expect(hasNestedQuantifier('((ab)+)+')).toBe(true);
219
- });
220
- });
221
- });
222
-
223
- describe('ReDoS mitigation via matchesQuery', () => {
224
- it('rejects nested-quantifier patterns as never-matching', () => {
225
- expect(matchesQuery('(a+)+', 'aaaaaaaaaa')).toBe(false);
226
- expect(matchesQuery('(.*)*', 'hello')).toBe(false);
227
- });
228
-
229
- it('does not stall on an adversarial input that would backtrack', () => {
230
- const adversarial = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!';
231
- const start = Date.now();
232
- const result = matchesQuery('(a+)+$', adversarial);
233
- const elapsed = Date.now() - start;
234
- expect(result).toBe(false);
235
- expect(elapsed).toBeLessThan(200);
236
- });
237
- });
238
- });