@librechat/agents 3.1.66-dev.0 → 3.1.67
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.
- package/dist/cjs/agents/AgentContext.cjs +24 -15
- package/dist/cjs/agents/AgentContext.cjs.map +1 -1
- package/dist/cjs/common/enum.cjs +0 -13
- package/dist/cjs/common/enum.cjs.map +1 -1
- package/dist/cjs/graphs/Graph.cjs +0 -3
- package/dist/cjs/graphs/Graph.cjs.map +1 -1
- package/dist/cjs/main.cjs +0 -40
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/messages/format.cjs +12 -74
- package/dist/cjs/messages/format.cjs.map +1 -1
- package/dist/cjs/run.cjs +0 -111
- package/dist/cjs/run.cjs.map +1 -1
- package/dist/cjs/tools/ToolNode.cjs +140 -304
- package/dist/cjs/tools/ToolNode.cjs.map +1 -1
- package/dist/esm/agents/AgentContext.mjs +24 -15
- package/dist/esm/agents/AgentContext.mjs.map +1 -1
- package/dist/esm/common/enum.mjs +1 -12
- package/dist/esm/common/enum.mjs.map +1 -1
- package/dist/esm/graphs/Graph.mjs +0 -3
- package/dist/esm/graphs/Graph.mjs.map +1 -1
- package/dist/esm/main.mjs +1 -10
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/messages/format.mjs +4 -66
- package/dist/esm/messages/format.mjs.map +1 -1
- package/dist/esm/run.mjs +0 -111
- package/dist/esm/run.mjs.map +1 -1
- package/dist/esm/tools/ToolNode.mjs +142 -306
- package/dist/esm/tools/ToolNode.mjs.map +1 -1
- package/dist/types/agents/AgentContext.d.ts +6 -0
- package/dist/types/common/enum.d.ts +1 -7
- package/dist/types/graphs/Graph.d.ts +0 -2
- package/dist/types/index.d.ts +0 -6
- package/dist/types/messages/format.d.ts +1 -2
- package/dist/types/run.d.ts +0 -1
- package/dist/types/tools/ToolNode.d.ts +2 -24
- package/dist/types/types/index.d.ts +0 -1
- package/dist/types/types/llm.d.ts +14 -2
- package/dist/types/types/run.d.ts +0 -20
- package/dist/types/types/tools.d.ts +1 -38
- package/package.json +1 -1
- package/src/agents/AgentContext.ts +28 -15
- package/src/agents/__tests__/AgentContext.test.ts +110 -0
- package/src/common/enum.ts +0 -12
- package/src/graphs/Graph.ts +0 -4
- package/src/index.ts +0 -8
- package/src/messages/format.ts +4 -74
- package/src/run.ts +0 -126
- package/src/tools/ToolNode.ts +169 -391
- package/src/tools/__tests__/ToolNode.session.test.ts +12 -12
- package/src/types/index.ts +0 -1
- package/src/types/llm.ts +16 -2
- package/src/types/run.ts +0 -20
- package/src/types/tools.ts +1 -41
- package/dist/cjs/hooks/HookRegistry.cjs +0 -162
- package/dist/cjs/hooks/HookRegistry.cjs.map +0 -1
- package/dist/cjs/hooks/executeHooks.cjs +0 -276
- package/dist/cjs/hooks/executeHooks.cjs.map +0 -1
- package/dist/cjs/hooks/matchers.cjs +0 -256
- package/dist/cjs/hooks/matchers.cjs.map +0 -1
- package/dist/cjs/hooks/types.cjs +0 -27
- package/dist/cjs/hooks/types.cjs.map +0 -1
- package/dist/cjs/tools/BashExecutor.cjs +0 -175
- package/dist/cjs/tools/BashExecutor.cjs.map +0 -1
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs +0 -296
- package/dist/cjs/tools/BashProgrammaticToolCalling.cjs.map +0 -1
- package/dist/cjs/tools/ReadFile.cjs +0 -43
- package/dist/cjs/tools/ReadFile.cjs.map +0 -1
- package/dist/cjs/tools/SkillTool.cjs +0 -50
- package/dist/cjs/tools/SkillTool.cjs.map +0 -1
- package/dist/cjs/tools/skillCatalog.cjs +0 -84
- package/dist/cjs/tools/skillCatalog.cjs.map +0 -1
- package/dist/esm/hooks/HookRegistry.mjs +0 -160
- package/dist/esm/hooks/HookRegistry.mjs.map +0 -1
- package/dist/esm/hooks/executeHooks.mjs +0 -273
- package/dist/esm/hooks/executeHooks.mjs.map +0 -1
- package/dist/esm/hooks/matchers.mjs +0 -251
- package/dist/esm/hooks/matchers.mjs.map +0 -1
- package/dist/esm/hooks/types.mjs +0 -25
- package/dist/esm/hooks/types.mjs.map +0 -1
- package/dist/esm/tools/BashExecutor.mjs +0 -169
- package/dist/esm/tools/BashExecutor.mjs.map +0 -1
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs +0 -287
- package/dist/esm/tools/BashProgrammaticToolCalling.mjs.map +0 -1
- package/dist/esm/tools/ReadFile.mjs +0 -38
- package/dist/esm/tools/ReadFile.mjs.map +0 -1
- package/dist/esm/tools/SkillTool.mjs +0 -45
- package/dist/esm/tools/SkillTool.mjs.map +0 -1
- package/dist/esm/tools/skillCatalog.mjs +0 -82
- package/dist/esm/tools/skillCatalog.mjs.map +0 -1
- package/dist/types/hooks/HookRegistry.d.ts +0 -56
- package/dist/types/hooks/executeHooks.d.ts +0 -79
- package/dist/types/hooks/index.d.ts +0 -6
- package/dist/types/hooks/matchers.d.ts +0 -95
- package/dist/types/hooks/types.d.ts +0 -309
- package/dist/types/tools/BashExecutor.d.ts +0 -45
- package/dist/types/tools/BashProgrammaticToolCalling.d.ts +0 -72
- package/dist/types/tools/ReadFile.d.ts +0 -28
- package/dist/types/tools/SkillTool.d.ts +0 -40
- package/dist/types/tools/skillCatalog.d.ts +0 -19
- package/dist/types/types/skill.d.ts +0 -9
- package/src/hooks/HookRegistry.ts +0 -208
- package/src/hooks/__tests__/HookRegistry.test.ts +0 -190
- package/src/hooks/__tests__/executeHooks.test.ts +0 -1013
- package/src/hooks/__tests__/integration.test.ts +0 -337
- package/src/hooks/__tests__/matchers.test.ts +0 -238
- package/src/hooks/__tests__/toolHooks.test.ts +0 -669
- package/src/hooks/executeHooks.ts +0 -375
- package/src/hooks/index.ts +0 -55
- package/src/hooks/matchers.ts +0 -280
- package/src/hooks/types.ts +0 -388
- package/src/messages/formatAgentMessages.skills.test.ts +0 -334
- package/src/tools/BashExecutor.ts +0 -205
- package/src/tools/BashProgrammaticToolCalling.ts +0 -397
- package/src/tools/ReadFile.ts +0 -39
- package/src/tools/SkillTool.ts +0 -46
- package/src/tools/__tests__/ReadFile.test.ts +0 -44
- package/src/tools/__tests__/SkillTool.test.ts +0 -442
- package/src/tools/__tests__/skillCatalog.test.ts +0 -161
- package/src/tools/skillCatalog.ts +0 -126
- 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
|
-
});
|