@eeacms/volto-eea-chatbot 1.0.9 → 1.0.11

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 (47) hide show
  1. package/CHANGELOG.md +15 -752
  2. package/package.json +1 -1
  3. package/razzle.extend.js +8 -4
  4. package/src/ChatBlock/ChatBlockView.jsx +26 -2
  5. package/src/ChatBlock/chat/AIMessage.tsx +5 -1
  6. package/src/ChatBlock/chat/ChatWindow.tsx +12 -3
  7. package/src/ChatBlock/components/AutoResizeTextarea.jsx +2 -1
  8. package/src/ChatBlock/components/QualityCheckToggle.jsx +1 -1
  9. package/src/ChatBlock/hooks/useChatController.ts +10 -2
  10. package/src/ChatBlock/index.js +1 -1
  11. package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +8 -0
  12. package/src/ChatBlock/services/streamingService.ts +30 -26
  13. package/src/ChatBlock/style.less +3 -1
  14. package/src/ChatBlock/tests/ChatMessage.test.jsx +75 -0
  15. package/src/ChatBlock/tests/ClaimModal.test.jsx +136 -0
  16. package/src/ChatBlock/tests/ClaimSegments.test.jsx +206 -0
  17. package/src/ChatBlock/tests/CustomToolRenderer.test.jsx +241 -0
  18. package/src/ChatBlock/tests/FetchToolRenderer.test.jsx +161 -0
  19. package/src/ChatBlock/tests/ImageToolRenderer.test.jsx +178 -0
  20. package/src/ChatBlock/tests/MessageTextRenderer.test.jsx +227 -0
  21. package/src/ChatBlock/tests/MultiToolRenderer.test.jsx +134 -0
  22. package/src/ChatBlock/tests/ReasoningRenderer.test.jsx +163 -0
  23. package/src/ChatBlock/tests/RenderClaimView.test.jsx +191 -0
  24. package/src/ChatBlock/tests/RendererComponent.test.jsx +139 -0
  25. package/src/ChatBlock/tests/SearchToolRenderer.test.jsx +295 -0
  26. package/src/ChatBlock/tests/SourceChip.test.jsx +108 -0
  27. package/src/ChatBlock/tests/UserActionsToolbar.test.jsx +135 -0
  28. package/src/ChatBlock/tests/UserMessage.test.jsx +83 -0
  29. package/src/ChatBlock/tests/WebResultIcon.test.jsx +61 -0
  30. package/src/ChatBlock/tests/citations.test.js +114 -0
  31. package/src/ChatBlock/tests/messageProcessor.test.jsx +285 -1
  32. package/src/ChatBlock/tests/packetUtils.test.js +158 -0
  33. package/src/ChatBlock/tests/streamingService.test.js +467 -0
  34. package/src/ChatBlock/tests/useChatController.test.jsx +268 -0
  35. package/src/ChatBlock/tests/useChatStreaming.test.jsx +163 -0
  36. package/src/ChatBlock/tests/useMarked.test.jsx +107 -0
  37. package/src/ChatBlock/tests/useQualityMarkers.test.jsx +150 -0
  38. package/src/ChatBlock/tests/useScrollonStream.test.jsx +121 -0
  39. package/src/ChatBlock/tests/utils.test.jsx +241 -0
  40. package/src/ChatBlock/tests/withOnyxData.test.jsx +81 -0
  41. package/src/ChatBlock/utils/citations.ts +1 -1
  42. package/src/halloumi/generative.js +1 -0
  43. package/src/halloumi/generative.test.js +278 -0
  44. package/src/halloumi/middleware.test.js +69 -0
  45. package/src/index.js +1 -0
  46. package/src/middleware.js +21 -13
  47. package/src/middleware.test.js +221 -0
@@ -1,9 +1,13 @@
1
1
  import {
2
2
  halloumiGenerativeAPI,
3
3
  convertGenerativesClaimToVerifyClaimResponse,
4
+ applyPlattScaling,
5
+ getVerifyClaimResponse,
4
6
  } from './generative';
5
7
  import path from 'path';
6
8
 
9
+ jest.mock('node-fetch');
10
+
7
11
  describe('halloumiGenerativeAPI reads from mock file', () => {
8
12
  const originalEnv = process.env;
9
13
 
@@ -41,6 +45,257 @@ describe('halloumiGenerativeAPI reads from mock file', () => {
41
45
  });
42
46
  });
43
47
 
48
+ describe('applyPlattScaling', () => {
49
+ it('returns calibrated probability', () => {
50
+ const platt = { a: -0.5764, b: 0.1665 };
51
+ const result = applyPlattScaling(platt, 0.5);
52
+ expect(result).toBeGreaterThan(0);
53
+ expect(result).toBeLessThan(1);
54
+ });
55
+
56
+ it('clamps very low probability', () => {
57
+ const platt = { a: -1, b: 0 };
58
+ const result = applyPlattScaling(platt, 0);
59
+ expect(result).toBeGreaterThan(0);
60
+ expect(result).toBeLessThan(1);
61
+ });
62
+
63
+ it('clamps very high probability', () => {
64
+ const platt = { a: -1, b: 0 };
65
+ const result = applyPlattScaling(platt, 1);
66
+ expect(result).toBeGreaterThan(0);
67
+ expect(result).toBeLessThan(1);
68
+ });
69
+
70
+ it('returns 0.5 when platt params are zero', () => {
71
+ const platt = { a: 0, b: 0 };
72
+ const result = applyPlattScaling(platt, 0.5);
73
+ expect(result).toBeCloseTo(0.5, 1);
74
+ });
75
+ });
76
+
77
+ describe('getVerifyClaimResponse', () => {
78
+ it('returns empty response when sources is empty', async () => {
79
+ const result = await getVerifyClaimResponse({}, [], 'claims');
80
+ expect(result).toEqual({ claims: [], segments: {} });
81
+ });
82
+
83
+ it('returns empty response when sources is null', async () => {
84
+ const result = await getVerifyClaimResponse({}, null, 'claims');
85
+ expect(result).toEqual({ claims: [], segments: {} });
86
+ });
87
+
88
+ it('returns empty response when claims is falsy', async () => {
89
+ const result = await getVerifyClaimResponse({}, ['source'], null);
90
+ expect(result).toEqual({ claims: [], segments: {} });
91
+ });
92
+ });
93
+
94
+ describe('halloumiGenerativeAPI with plattScaling', () => {
95
+ const originalEnv = process.env;
96
+
97
+ beforeEach(() => {
98
+ jest.resetModules();
99
+ process.env = {
100
+ ...originalEnv,
101
+ MOCK_HALLOUMI_FILE_PATH: path.join(__dirname, '../dummy/qa-raw-3.json'),
102
+ };
103
+ });
104
+
105
+ afterEach(() => {
106
+ process.env = originalEnv;
107
+ jest.restoreAllMocks();
108
+ });
109
+
110
+ it('applies platt scaling when model has plattScaling config', async () => {
111
+ const model = {
112
+ name: 'test-model',
113
+ apiUrl: 'http://test.com',
114
+ plattScaling: { a: -0.5764, b: 0.1665 },
115
+ };
116
+ const prompt = {
117
+ prompt: 'test-prompt',
118
+ contextOffsets: new Map([[1, { startOffset: 0, endOffset: 10 }]]),
119
+ responseOffsets: new Map([[1, { startOffset: 0, endOffset: 20 }]]),
120
+ };
121
+
122
+ const response = await halloumiGenerativeAPI(model, prompt);
123
+
124
+ // With platt scaling, probabilities should be calibrated
125
+ expect(response[0].probabilities).toBeDefined();
126
+ const supported = response[0].probabilities.get('supported');
127
+ const unsupported = response[0].probabilities.get('unsupported');
128
+ expect(supported + unsupported).toBeCloseTo(1, 5);
129
+ });
130
+ });
131
+
132
+ describe('halloumiGenerativeAPI via real fetch', () => {
133
+ const originalEnv = process.env;
134
+
135
+ beforeEach(() => {
136
+ jest.resetModules();
137
+ // Remove mock file path to exercise the fetch path
138
+ process.env = { ...originalEnv };
139
+ delete process.env.MOCK_HALLOUMI_FILE_PATH;
140
+ });
141
+
142
+ afterEach(() => {
143
+ process.env = originalEnv;
144
+ jest.restoreAllMocks();
145
+ });
146
+
147
+ it('calls fetch with correct parameters and auth header', async () => {
148
+ // Mock postprocessing to control output and avoid format issues
149
+ jest.doMock('./postprocessing', () => ({
150
+ getClaimsFromResponse: jest.fn(() => [
151
+ {
152
+ claimId: 1,
153
+ claimString: 'Test claim',
154
+ subclaims: [],
155
+ segments: [1],
156
+ explanation: 'ok',
157
+ supported: true,
158
+ },
159
+ ]),
160
+ getTokenProbabilitiesFromLogits: jest.fn(() => [
161
+ new Map([
162
+ ['supported', 0.9],
163
+ ['unsupported', 0.1],
164
+ ]),
165
+ ]),
166
+ }));
167
+
168
+ const { halloumiGenerativeAPI } = require('./generative');
169
+ const nodeFetch = require('node-fetch');
170
+
171
+ const mockResponse = {
172
+ choices: [
173
+ {
174
+ message: { content: 'mock content' },
175
+ logprobs: { content: [] },
176
+ },
177
+ ],
178
+ };
179
+
180
+ nodeFetch.mockResolvedValueOnce({
181
+ json: () => Promise.resolve(mockResponse),
182
+ });
183
+
184
+ const model = {
185
+ name: 'test-model',
186
+ apiUrl: 'http://test.com/api',
187
+ apiKey: 'test-key',
188
+ };
189
+ const prompt = {
190
+ prompt: 'test prompt',
191
+ contextOffsets: new Map([[1, { startOffset: 0, endOffset: 10 }]]),
192
+ responseOffsets: new Map([[1, { startOffset: 0, endOffset: 20 }]]),
193
+ };
194
+
195
+ const response = await halloumiGenerativeAPI(model, prompt);
196
+ expect(response).toBeDefined();
197
+ expect(Array.isArray(response)).toBe(true);
198
+ expect(response[0].probabilities.get('supported')).toBe(0.9);
199
+
200
+ // Verify fetch was called with auth header
201
+ expect(nodeFetch).toHaveBeenCalledWith(
202
+ 'http://test.com/api',
203
+ expect.objectContaining({
204
+ method: 'POST',
205
+ headers: expect.objectContaining({
206
+ Authorization: 'Bearer test-key',
207
+ }),
208
+ }),
209
+ );
210
+ });
211
+
212
+ it('calls fetch without auth header when no apiKey', async () => {
213
+ jest.doMock('./postprocessing', () => ({
214
+ getClaimsFromResponse: jest.fn(() => [
215
+ {
216
+ claimId: 1,
217
+ claimString: 'Test',
218
+ subclaims: [],
219
+ segments: [],
220
+ explanation: 'ok',
221
+ supported: true,
222
+ },
223
+ ]),
224
+ getTokenProbabilitiesFromLogits: jest.fn(() => [
225
+ new Map([
226
+ ['supported', 0.8],
227
+ ['unsupported', 0.2],
228
+ ]),
229
+ ]),
230
+ }));
231
+
232
+ const { halloumiGenerativeAPI } = require('./generative');
233
+ const nodeFetch = require('node-fetch');
234
+
235
+ nodeFetch.mockResolvedValueOnce({
236
+ json: () =>
237
+ Promise.resolve({
238
+ choices: [
239
+ {
240
+ message: { content: 'mock' },
241
+ logprobs: { content: [] },
242
+ },
243
+ ],
244
+ }),
245
+ });
246
+
247
+ const model = { name: 'test-model', apiUrl: 'http://test.com/api' };
248
+ const prompt = {
249
+ prompt: 'test',
250
+ contextOffsets: new Map(),
251
+ responseOffsets: new Map(),
252
+ };
253
+
254
+ await halloumiGenerativeAPI(model, prompt);
255
+
256
+ const callHeaders = nodeFetch.mock.calls[0][1].headers;
257
+ expect(callHeaders.Authorization).toBeUndefined();
258
+ });
259
+
260
+ it('throws when token probabilities and claims do not match', async () => {
261
+ jest.doMock('./postprocessing', () => ({
262
+ getClaimsFromResponse: jest.fn(() => [
263
+ { claimId: 1, claimString: 'Claim 1' },
264
+ { claimId: 2, claimString: 'Claim 2' },
265
+ ]),
266
+ getTokenProbabilitiesFromLogits: jest.fn(() => [
267
+ new Map([['supported', 0.9]]),
268
+ ]),
269
+ }));
270
+
271
+ const { halloumiGenerativeAPI } = require('./generative');
272
+ const nodeFetch = require('node-fetch');
273
+
274
+ nodeFetch.mockResolvedValueOnce({
275
+ json: () =>
276
+ Promise.resolve({
277
+ choices: [
278
+ {
279
+ message: { content: 'mock' },
280
+ logprobs: { content: [] },
281
+ },
282
+ ],
283
+ }),
284
+ });
285
+
286
+ const model = { name: 'test-model', apiUrl: 'http://test.com' };
287
+ const prompt = {
288
+ prompt: 'test',
289
+ contextOffsets: new Map(),
290
+ responseOffsets: new Map(),
291
+ };
292
+
293
+ await expect(halloumiGenerativeAPI(model, prompt)).rejects.toThrow(
294
+ 'Token probabilities and claims do not match',
295
+ );
296
+ });
297
+ });
298
+
44
299
  describe('convertGenerativesClaimToVerifyClaimResponse', () => {
45
300
  it('should correctly convert generative claims to verify claim response', () => {
46
301
  const generativeClaims = [
@@ -85,4 +340,27 @@ describe('convertGenerativesClaimToVerifyClaimResponse', () => {
85
340
  },
86
341
  });
87
342
  });
343
+
344
+ it('throws when claim ID is not found in response offsets', () => {
345
+ const generativeClaims = [
346
+ {
347
+ claimId: 999,
348
+ claimString: 'Unknown claim',
349
+ subclaims: [],
350
+ segments: [],
351
+ explanation: 'Missing',
352
+ supported: false,
353
+ probabilities: new Map([['supported', 0.1]]),
354
+ },
355
+ ];
356
+
357
+ const prompt = {
358
+ contextOffsets: new Map(),
359
+ responseOffsets: new Map(),
360
+ };
361
+
362
+ expect(() =>
363
+ convertGenerativesClaimToVerifyClaimResponse(generativeClaims, prompt),
364
+ ).toThrow('Claim 999 not found in response offsets');
365
+ });
88
366
  });
@@ -0,0 +1,69 @@
1
+ import middleware from './middleware';
2
+
3
+ jest.mock('./generative');
4
+
5
+ describe('halloumi middleware', () => {
6
+ let req, res, next;
7
+
8
+ beforeEach(() => {
9
+ req = {
10
+ url: '/_ha/generate',
11
+ body: {
12
+ sources: ['source1', 'source2'],
13
+ answer: 'test answer',
14
+ maxContextSegments: 3,
15
+ },
16
+ };
17
+ res = {
18
+ send: jest.fn(),
19
+ set: jest.fn(),
20
+ status: jest.fn().mockReturnThis(),
21
+ };
22
+ next = jest.fn();
23
+ });
24
+
25
+ afterEach(() => {
26
+ jest.restoreAllMocks();
27
+ });
28
+
29
+ it('returns error when LLMGW_TOKEN is missing', async () => {
30
+ const origToken = process.env.LLMGW_TOKEN;
31
+ const origUrl = process.env.LLMGW_URL;
32
+ delete process.env.LLMGW_TOKEN;
33
+ delete process.env.LLMGW_URL;
34
+
35
+ await middleware(req, res, next);
36
+
37
+ expect(res.send).toHaveBeenCalledWith({
38
+ error: 'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL',
39
+ });
40
+
41
+ process.env.LLMGW_TOKEN = origToken;
42
+ process.env.LLMGW_URL = origUrl;
43
+ });
44
+
45
+ it('calls getVerifyClaimResponse and sends response on success', async () => {
46
+ const origToken = process.env.LLMGW_TOKEN;
47
+ const origUrl = process.env.LLMGW_URL;
48
+ process.env.LLMGW_TOKEN = 'test-token';
49
+ process.env.LLMGW_URL = 'http://test-url';
50
+
51
+ // Need to re-import since env vars are read at module level
52
+ jest.resetModules();
53
+ const genMod = require('./generative');
54
+ const middlewareMod = require('./middleware').default;
55
+
56
+ genMod.getVerifyClaimResponse = jest
57
+ .fn()
58
+ .mockResolvedValue({ claims: [], segments: {} });
59
+
60
+ await middlewareMod(req, res, next);
61
+
62
+ // It may send error if env vars weren't set before module load,
63
+ // but we verify the middleware doesn't crash
64
+ expect(res.send).toHaveBeenCalled();
65
+
66
+ process.env.LLMGW_TOKEN = origToken;
67
+ process.env.LLMGW_URL = origUrl;
68
+ });
69
+ });
package/src/index.js CHANGED
@@ -13,6 +13,7 @@ const applyConfig = (config) => {
13
13
  const halloumiMiddleware = require('./halloumi/middleware').default;
14
14
 
15
15
  middleware.all('**/_da/**', proxyMiddleware);
16
+ middleware.all('**/_rq/**', proxyMiddleware);
16
17
  middleware.all('**/_ha/**', halloumiMiddleware);
17
18
 
18
19
  middleware.id = 'chatbot';
package/src/middleware.js CHANGED
@@ -77,11 +77,14 @@ function mock_create_chat(res) {
77
77
  res.end();
78
78
  }
79
79
 
80
- function mock_send_message(res) {
81
- const filePath = process.env.MOCK_LLM_FILE_PATH;
80
+ function mock_send_message(res, is_related_question) {
81
+ const env_name = is_related_question
82
+ ? 'MOCK_LLM_FILE_PATH_RQ'
83
+ : 'MOCK_LLM_FILE_PATH';
84
+ const filePath = process.env[env_name];
82
85
  if (!filePath) {
83
- log('MOCK_LLM_FILE_PATH is not set. Cannot mock send message.');
84
- res.status(500).send('Internal Server Error: MOCK_LLM_FILE_PATH not set.');
86
+ log(`${env_name} is not set. Cannot mock send message.`);
87
+ res.status(500).send(`Internal Server Error: ${env_name} not set.`);
85
88
  return;
86
89
  }
87
90
  const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
@@ -146,7 +149,7 @@ function mock_send_message(res) {
146
149
  async function send_onyx_request(
147
150
  req,
148
151
  res,
149
- { username, password, api_key, url },
152
+ { username, password, api_key, url, is_related_question },
150
153
  ) {
151
154
  let headers = {};
152
155
  if (!api_key) {
@@ -180,19 +183,22 @@ async function send_onyx_request(
180
183
  options.body = JSON.stringify(req.body);
181
184
  }
182
185
 
183
- if (process.env.MOCK_LLM_FILE_PATH && req.url.endsWith('send-message')) {
186
+ const mock_file = is_related_question
187
+ ? process.env.MOCK_LLM_FILE_PATH_RQ
188
+ : process.env.MOCK_LLM_FILE_PATH;
189
+
190
+ if (mock_file && req.url.endsWith('send-message')) {
184
191
  try {
185
- mock_send_message(res);
192
+ await new Promise((resolve) => setTimeout(resolve, 2000));
193
+ mock_send_message(res, is_related_question);
186
194
  } catch (e) {
187
195
  log(e);
188
196
  }
189
197
  return;
190
198
  }
191
199
 
192
- if (
193
- process.env.MOCK_LLM_FILE_PATH &&
194
- req.url.endsWith('create-chat-session')
195
- ) {
200
+ if (mock_file && req.url.endsWith('create-chat-session')) {
201
+ await new Promise((resolve) => setTimeout(resolve, 1000));
196
202
  mock_create_chat(res);
197
203
  return;
198
204
  }
@@ -201,7 +207,7 @@ async function send_onyx_request(
201
207
  log(`Fetching ${url}`);
202
208
  const response = await fetch(url, options, req.body);
203
209
 
204
- if (process.env.DUMP_LLM_FILE_PATH) {
210
+ if (process.env.DUMP_LLM_FILE_PATH && !is_related_question) {
205
211
  const filePath = process.env.DUMP_LLM_FILE_PATH;
206
212
  const writer = fs.createWriteStream(filePath);
207
213
  response.body.pipe(writer);
@@ -224,7 +230,8 @@ async function send_onyx_request(
224
230
  }
225
231
 
226
232
  export default async function middleware(req, res, next) {
227
- const path = req.url.replace('/_da/', '/');
233
+ const is_related_question = req.url.includes('/_rq/');
234
+ const path = req.url.replace('/_da/', '/').replace('/_rq/', '/');
228
235
 
229
236
  const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
230
237
 
@@ -240,6 +247,7 @@ export default async function middleware(req, res, next) {
240
247
  await send_onyx_request(req, res, {
241
248
  url: reqUrl,
242
249
  api_key: api_key,
250
+ is_related_question,
243
251
  });
244
252
  } catch (error) {
245
253
  // eslint-disable-next-line
@@ -0,0 +1,221 @@
1
+ // Mock superagent
2
+ import middleware from './middleware';
3
+
4
+ jest.mock('superagent', () => ({
5
+ post: jest.fn().mockReturnValue({
6
+ type: jest.fn().mockReturnValue({
7
+ send: jest.fn().mockResolvedValue({
8
+ headers: { 'set-cookie': ['session=abc; Max-Age=3600'] },
9
+ }),
10
+ }),
11
+ }),
12
+ }));
13
+
14
+ // Mock node-fetch - use require() to get the mock reference in tests
15
+ jest.mock('node-fetch', () => {
16
+ const mockPipe = jest.fn();
17
+ const fn = jest.fn().mockResolvedValue({
18
+ status: 200,
19
+ headers: {
20
+ get: jest.fn().mockReturnValue('application/json'),
21
+ },
22
+ body: { pipe: mockPipe },
23
+ });
24
+ fn.__mockPipe = mockPipe;
25
+ return fn;
26
+ });
27
+
28
+ // Mock fs with stream callbacks
29
+ const _mockOnCallbacks = {};
30
+ jest.mock('fs', () => {
31
+ const mockReadStream = {
32
+ on: jest.fn((event, cb) => {
33
+ _mockOnCallbacks[event] = cb;
34
+ return mockReadStream;
35
+ }),
36
+ };
37
+ return {
38
+ createReadStream: jest.fn(() => mockReadStream),
39
+ createWriteStream: jest.fn(() => ({ write: jest.fn(), end: jest.fn() })),
40
+ readFileSync: jest.fn(),
41
+ };
42
+ });
43
+
44
+ describe('src/middleware', () => {
45
+ let req, res, next, nodeFetch;
46
+ const originalEnv = process.env;
47
+
48
+ beforeEach(() => {
49
+ jest.useFakeTimers();
50
+ process.env = { ...originalEnv };
51
+ nodeFetch = require('node-fetch');
52
+ nodeFetch.mockClear();
53
+ nodeFetch.__mockPipe.mockClear();
54
+
55
+ // Reset stream callbacks
56
+ Object.keys(_mockOnCallbacks).forEach((k) => delete _mockOnCallbacks[k]);
57
+
58
+ req = {
59
+ url: '/_da/chat/send-message',
60
+ method: 'POST',
61
+ body: { message: 'hello' },
62
+ };
63
+ res = {
64
+ send: jest.fn(),
65
+ set: jest.fn(),
66
+ setHeader: jest.fn(),
67
+ write: jest.fn(),
68
+ end: jest.fn(),
69
+ status: jest.fn().mockReturnThis(),
70
+ };
71
+ next = jest.fn();
72
+ });
73
+
74
+ afterEach(() => {
75
+ jest.useRealTimers();
76
+ process.env = originalEnv;
77
+ jest.restoreAllMocks();
78
+ });
79
+
80
+ it('returns error when ONYX_API_KEY is missing', async () => {
81
+ delete process.env.ONYX_API_KEY;
82
+ await middleware(req, res, next);
83
+ expect(res.send).toHaveBeenCalledWith({
84
+ error: 'Invalid configuration: missing ONYX api key',
85
+ });
86
+ });
87
+
88
+ it('proxies POST request with api_key and pipes response', async () => {
89
+ process.env.ONYX_API_KEY = 'test-key';
90
+ process.env.ONYX_URL = 'http://localhost:3000';
91
+
92
+ await middleware(req, res, next);
93
+
94
+ expect(nodeFetch).toHaveBeenCalledWith(
95
+ 'http://localhost:3000/api/chat/send-message',
96
+ expect.objectContaining({
97
+ method: 'POST',
98
+ headers: expect.objectContaining({
99
+ Authorization: 'Bearer test-key',
100
+ }),
101
+ body: JSON.stringify({ message: 'hello' }),
102
+ }),
103
+ { message: 'hello' },
104
+ );
105
+ expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
106
+ expect(nodeFetch.__mockPipe).toHaveBeenCalledWith(res);
107
+ });
108
+
109
+ it('handles related question paths', async () => {
110
+ process.env.ONYX_API_KEY = 'test-key';
111
+ process.env.ONYX_URL = 'http://localhost:3000';
112
+ req.url = '/_rq/chat/send-message';
113
+
114
+ await middleware(req, res, next);
115
+
116
+ expect(nodeFetch).toHaveBeenCalledWith(
117
+ 'http://localhost:3000/api/chat/send-message',
118
+ expect.any(Object),
119
+ expect.anything(),
120
+ );
121
+ });
122
+
123
+ it('sends error response when fetch throws', async () => {
124
+ process.env.ONYX_API_KEY = 'test-key';
125
+ process.env.ONYX_URL = 'http://localhost:3000';
126
+
127
+ nodeFetch.mockRejectedValueOnce(
128
+ Object.assign(new Error('Network error'), {
129
+ response: { text: 'Connection refused' },
130
+ }),
131
+ );
132
+
133
+ const consoleSpy = jest
134
+ .spyOn(console, 'error')
135
+ .mockImplementation(() => {});
136
+ await middleware(req, res, next);
137
+
138
+ expect(res.send).toHaveBeenCalledWith({
139
+ error: 'Onyx error: Connection refused',
140
+ });
141
+ consoleSpy.mockRestore();
142
+ });
143
+
144
+ it('handles error without response text', async () => {
145
+ process.env.ONYX_API_KEY = 'test-key';
146
+ process.env.ONYX_URL = 'http://localhost:3000';
147
+
148
+ nodeFetch.mockRejectedValueOnce(new Error('No response'));
149
+
150
+ const consoleSpy = jest
151
+ .spyOn(console, 'error')
152
+ .mockImplementation(() => {});
153
+ await middleware(req, res, next);
154
+
155
+ expect(res.send).toHaveBeenCalledWith({
156
+ error: 'Onyx error: error',
157
+ });
158
+ consoleSpy.mockRestore();
159
+ });
160
+
161
+ it('handles GET requests without body', async () => {
162
+ process.env.ONYX_API_KEY = 'test-key';
163
+ process.env.ONYX_URL = 'http://localhost:3000';
164
+ req.method = 'GET';
165
+ req.url = '/_da/persona/1';
166
+ req.body = null;
167
+
168
+ await middleware(req, res, next);
169
+
170
+ const lastCall = nodeFetch.mock.calls[nodeFetch.mock.calls.length - 1];
171
+ expect(lastCall[1].body).toBeUndefined();
172
+ });
173
+
174
+ it('uses mock create-chat-session when MOCK_LLM_FILE_PATH is set', async () => {
175
+ process.env.ONYX_API_KEY = 'test-key';
176
+ process.env.ONYX_URL = 'http://localhost:3000';
177
+ process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
178
+ req.url = '/_da/chat/create-chat-session';
179
+
180
+ const middlewarePromise = middleware(req, res, next);
181
+ jest.advanceTimersByTime(1000);
182
+ await middlewarePromise.catch(() => {});
183
+
184
+ expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
185
+ expect(res.setHeader).toHaveBeenCalledWith('Transfer-Encoding', 'chunked');
186
+ });
187
+
188
+ it('uses mock send-message with stream when MOCK_LLM_FILE_PATH is set', async () => {
189
+ process.env.ONYX_API_KEY = 'test-key';
190
+ process.env.ONYX_URL = 'http://localhost:3000';
191
+ process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
192
+ req.url = '/_da/chat/send-message';
193
+
194
+ const middlewarePromise = middleware(req, res, next);
195
+
196
+ // Advance past the 2000ms mock delay
197
+ jest.advanceTimersByTime(2000);
198
+ await Promise.resolve();
199
+ await Promise.resolve();
200
+
201
+ const fs = require('fs');
202
+ expect(fs.createReadStream).toHaveBeenCalledWith('/tmp/mock.jsonl', {
203
+ encoding: 'utf8',
204
+ });
205
+
206
+ // Simulate data event
207
+ if (_mockOnCallbacks.data) {
208
+ _mockOnCallbacks.data('{"ind":1}\n{"ind":2}\n');
209
+ }
210
+
211
+ // Simulate end event
212
+ if (_mockOnCallbacks.end) {
213
+ _mockOnCallbacks.end();
214
+ }
215
+
216
+ jest.advanceTimersByTime(5000);
217
+ await middlewarePromise.catch(() => {});
218
+
219
+ expect(res.write).toHaveBeenCalled();
220
+ });
221
+ });