@vibescope/mcp-server 0.0.1

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 (170) hide show
  1. package/README.md +98 -0
  2. package/dist/cli.d.ts +34 -0
  3. package/dist/cli.js +356 -0
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +367 -0
  6. package/dist/handlers/__test-utils__.d.ts +72 -0
  7. package/dist/handlers/__test-utils__.js +176 -0
  8. package/dist/handlers/blockers.d.ts +18 -0
  9. package/dist/handlers/blockers.js +81 -0
  10. package/dist/handlers/bodies-of-work.d.ts +34 -0
  11. package/dist/handlers/bodies-of-work.js +614 -0
  12. package/dist/handlers/checkouts.d.ts +37 -0
  13. package/dist/handlers/checkouts.js +377 -0
  14. package/dist/handlers/cost.d.ts +39 -0
  15. package/dist/handlers/cost.js +247 -0
  16. package/dist/handlers/decisions.d.ts +16 -0
  17. package/dist/handlers/decisions.js +64 -0
  18. package/dist/handlers/deployment.d.ts +36 -0
  19. package/dist/handlers/deployment.js +1062 -0
  20. package/dist/handlers/discovery.d.ts +14 -0
  21. package/dist/handlers/discovery.js +870 -0
  22. package/dist/handlers/fallback.d.ts +18 -0
  23. package/dist/handlers/fallback.js +216 -0
  24. package/dist/handlers/findings.d.ts +18 -0
  25. package/dist/handlers/findings.js +110 -0
  26. package/dist/handlers/git-issues.d.ts +22 -0
  27. package/dist/handlers/git-issues.js +247 -0
  28. package/dist/handlers/ideas.d.ts +19 -0
  29. package/dist/handlers/ideas.js +188 -0
  30. package/dist/handlers/index.d.ts +29 -0
  31. package/dist/handlers/index.js +65 -0
  32. package/dist/handlers/knowledge-query.d.ts +22 -0
  33. package/dist/handlers/knowledge-query.js +253 -0
  34. package/dist/handlers/knowledge.d.ts +12 -0
  35. package/dist/handlers/knowledge.js +108 -0
  36. package/dist/handlers/milestones.d.ts +20 -0
  37. package/dist/handlers/milestones.js +179 -0
  38. package/dist/handlers/organizations.d.ts +36 -0
  39. package/dist/handlers/organizations.js +428 -0
  40. package/dist/handlers/progress.d.ts +14 -0
  41. package/dist/handlers/progress.js +149 -0
  42. package/dist/handlers/project.d.ts +20 -0
  43. package/dist/handlers/project.js +278 -0
  44. package/dist/handlers/requests.d.ts +16 -0
  45. package/dist/handlers/requests.js +131 -0
  46. package/dist/handlers/roles.d.ts +30 -0
  47. package/dist/handlers/roles.js +281 -0
  48. package/dist/handlers/session.d.ts +20 -0
  49. package/dist/handlers/session.js +791 -0
  50. package/dist/handlers/tasks.d.ts +52 -0
  51. package/dist/handlers/tasks.js +1111 -0
  52. package/dist/handlers/tasks.test.d.ts +1 -0
  53. package/dist/handlers/tasks.test.js +431 -0
  54. package/dist/handlers/types.d.ts +94 -0
  55. package/dist/handlers/types.js +1 -0
  56. package/dist/handlers/validation.d.ts +16 -0
  57. package/dist/handlers/validation.js +188 -0
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.js +2707 -0
  60. package/dist/knowledge.d.ts +6 -0
  61. package/dist/knowledge.js +121 -0
  62. package/dist/tools.d.ts +2 -0
  63. package/dist/tools.js +2498 -0
  64. package/dist/utils.d.ts +149 -0
  65. package/dist/utils.js +317 -0
  66. package/dist/utils.test.d.ts +1 -0
  67. package/dist/utils.test.js +532 -0
  68. package/dist/validators.d.ts +35 -0
  69. package/dist/validators.js +111 -0
  70. package/dist/validators.test.d.ts +1 -0
  71. package/dist/validators.test.js +176 -0
  72. package/package.json +44 -0
  73. package/src/cli.test.ts +442 -0
  74. package/src/cli.ts +439 -0
  75. package/src/handlers/__test-utils__.ts +217 -0
  76. package/src/handlers/blockers.test.ts +390 -0
  77. package/src/handlers/blockers.ts +110 -0
  78. package/src/handlers/bodies-of-work.test.ts +1276 -0
  79. package/src/handlers/bodies-of-work.ts +783 -0
  80. package/src/handlers/cost.test.ts +436 -0
  81. package/src/handlers/cost.ts +322 -0
  82. package/src/handlers/decisions.test.ts +401 -0
  83. package/src/handlers/decisions.ts +86 -0
  84. package/src/handlers/deployment.test.ts +516 -0
  85. package/src/handlers/deployment.ts +1289 -0
  86. package/src/handlers/discovery.test.ts +254 -0
  87. package/src/handlers/discovery.ts +969 -0
  88. package/src/handlers/fallback.test.ts +687 -0
  89. package/src/handlers/fallback.ts +260 -0
  90. package/src/handlers/findings.test.ts +565 -0
  91. package/src/handlers/findings.ts +153 -0
  92. package/src/handlers/ideas.test.ts +753 -0
  93. package/src/handlers/ideas.ts +247 -0
  94. package/src/handlers/index.ts +69 -0
  95. package/src/handlers/milestones.test.ts +584 -0
  96. package/src/handlers/milestones.ts +217 -0
  97. package/src/handlers/organizations.test.ts +997 -0
  98. package/src/handlers/organizations.ts +550 -0
  99. package/src/handlers/progress.test.ts +369 -0
  100. package/src/handlers/progress.ts +188 -0
  101. package/src/handlers/project.test.ts +562 -0
  102. package/src/handlers/project.ts +352 -0
  103. package/src/handlers/requests.test.ts +531 -0
  104. package/src/handlers/requests.ts +150 -0
  105. package/src/handlers/session.test.ts +459 -0
  106. package/src/handlers/session.ts +912 -0
  107. package/src/handlers/tasks.test.ts +602 -0
  108. package/src/handlers/tasks.ts +1393 -0
  109. package/src/handlers/types.ts +88 -0
  110. package/src/handlers/validation.test.ts +880 -0
  111. package/src/handlers/validation.ts +223 -0
  112. package/src/index.ts +3205 -0
  113. package/src/knowledge.ts +132 -0
  114. package/src/tmpclaude-0078-cwd +1 -0
  115. package/src/tmpclaude-0ee1-cwd +1 -0
  116. package/src/tmpclaude-2dd5-cwd +1 -0
  117. package/src/tmpclaude-344c-cwd +1 -0
  118. package/src/tmpclaude-3860-cwd +1 -0
  119. package/src/tmpclaude-4b63-cwd +1 -0
  120. package/src/tmpclaude-5c73-cwd +1 -0
  121. package/src/tmpclaude-5ee3-cwd +1 -0
  122. package/src/tmpclaude-6795-cwd +1 -0
  123. package/src/tmpclaude-709e-cwd +1 -0
  124. package/src/tmpclaude-9839-cwd +1 -0
  125. package/src/tmpclaude-d829-cwd +1 -0
  126. package/src/tmpclaude-e072-cwd +1 -0
  127. package/src/tmpclaude-f6ee-cwd +1 -0
  128. package/src/utils.test.ts +681 -0
  129. package/src/utils.ts +375 -0
  130. package/src/validators.test.ts +223 -0
  131. package/src/validators.ts +122 -0
  132. package/tmpclaude-0439-cwd +1 -0
  133. package/tmpclaude-132f-cwd +1 -0
  134. package/tmpclaude-15bb-cwd +1 -0
  135. package/tmpclaude-165a-cwd +1 -0
  136. package/tmpclaude-1ba9-cwd +1 -0
  137. package/tmpclaude-21a3-cwd +1 -0
  138. package/tmpclaude-2a38-cwd +1 -0
  139. package/tmpclaude-2adf-cwd +1 -0
  140. package/tmpclaude-2f56-cwd +1 -0
  141. package/tmpclaude-3626-cwd +1 -0
  142. package/tmpclaude-3727-cwd +1 -0
  143. package/tmpclaude-40bc-cwd +1 -0
  144. package/tmpclaude-436f-cwd +1 -0
  145. package/tmpclaude-4783-cwd +1 -0
  146. package/tmpclaude-4b6d-cwd +1 -0
  147. package/tmpclaude-4ba4-cwd +1 -0
  148. package/tmpclaude-51e6-cwd +1 -0
  149. package/tmpclaude-5ecf-cwd +1 -0
  150. package/tmpclaude-6f97-cwd +1 -0
  151. package/tmpclaude-7fb2-cwd +1 -0
  152. package/tmpclaude-825c-cwd +1 -0
  153. package/tmpclaude-8baf-cwd +1 -0
  154. package/tmpclaude-8d9f-cwd +1 -0
  155. package/tmpclaude-975c-cwd +1 -0
  156. package/tmpclaude-9983-cwd +1 -0
  157. package/tmpclaude-a045-cwd +1 -0
  158. package/tmpclaude-ac4a-cwd +1 -0
  159. package/tmpclaude-b593-cwd +1 -0
  160. package/tmpclaude-b891-cwd +1 -0
  161. package/tmpclaude-c032-cwd +1 -0
  162. package/tmpclaude-cf43-cwd +1 -0
  163. package/tmpclaude-d040-cwd +1 -0
  164. package/tmpclaude-dcdd-cwd +1 -0
  165. package/tmpclaude-dcee-cwd +1 -0
  166. package/tmpclaude-e16b-cwd +1 -0
  167. package/tmpclaude-ecd2-cwd +1 -0
  168. package/tmpclaude-f48d-cwd +1 -0
  169. package/tsconfig.json +16 -0
  170. package/vitest.config.ts +13 -0
@@ -0,0 +1,531 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { SupabaseClient } from '@supabase/supabase-js';
3
+ import type { HandlerContext, TokenUsage } from './types.js';
4
+ import { getPendingRequests, acknowledgeRequest, answerQuestion } from './requests.js';
5
+ import { ValidationError } from '../validators.js';
6
+
7
+ // ============================================================================
8
+ // Test Utilities
9
+ // ============================================================================
10
+
11
+ function createMockSupabase(overrides: {
12
+ selectResult?: { data: unknown; error: unknown };
13
+ updateResult?: { data: unknown; error: unknown };
14
+ sessionsResult?: { data: unknown; error: unknown };
15
+ } = {}) {
16
+ const defaultResult = { data: null, error: null };
17
+ // Use an object to track state so it persists across all mock function calls
18
+ const state = {
19
+ currentOperation: 'select' as string,
20
+ currentTable: '' as string,
21
+ updateCalled: false,
22
+ };
23
+
24
+ const mock = {
25
+ from: vi.fn((table: string) => {
26
+ state.currentTable = table;
27
+ // Reset state for new query chain
28
+ state.currentOperation = 'select';
29
+ state.updateCalled = false;
30
+ return mock;
31
+ }),
32
+ select: vi.fn(() => {
33
+ // Don't reset updateCalled - we need to know if update was in this chain
34
+ if (!state.updateCalled) {
35
+ state.currentOperation = 'select';
36
+ }
37
+ return mock;
38
+ }),
39
+ update: vi.fn(() => {
40
+ state.currentOperation = 'update';
41
+ state.updateCalled = true;
42
+ return mock;
43
+ }),
44
+ eq: vi.fn().mockReturnThis(),
45
+ or: vi.fn().mockReturnThis(),
46
+ order: vi.fn().mockReturnThis(),
47
+ single: vi.fn(() => {
48
+ // If update was called in this chain, return updateResult
49
+ if (state.updateCalled) {
50
+ return Promise.resolve(overrides.updateResult ?? defaultResult);
51
+ }
52
+ return Promise.resolve(overrides.selectResult ?? defaultResult);
53
+ }),
54
+ then: vi.fn((resolve: (value: unknown) => void) => {
55
+ if (state.currentTable === 'agent_sessions') {
56
+ return Promise.resolve(overrides.sessionsResult ?? { data: [], error: null }).then(resolve);
57
+ }
58
+ if (state.updateCalled) {
59
+ return Promise.resolve(overrides.updateResult ?? defaultResult).then(resolve);
60
+ }
61
+ if (state.currentOperation === 'select') {
62
+ return Promise.resolve(overrides.selectResult ?? defaultResult).then(resolve);
63
+ }
64
+ return Promise.resolve(defaultResult).then(resolve);
65
+ }),
66
+ };
67
+
68
+ return mock as unknown as SupabaseClient;
69
+ }
70
+
71
+ function createMockContext(
72
+ supabase: SupabaseClient,
73
+ options: { sessionId?: string | null } = {}
74
+ ): HandlerContext {
75
+ const defaultTokenUsage: TokenUsage = {
76
+ callCount: 5,
77
+ totalTokens: 2500,
78
+ byTool: {},
79
+ };
80
+
81
+ const sessionId = 'sessionId' in options ? options.sessionId : 'session-123';
82
+
83
+ return {
84
+ supabase,
85
+ auth: { userId: 'user-123', apiKeyId: 'api-key-123' },
86
+ session: {
87
+ instanceId: 'instance-abc',
88
+ currentSessionId: sessionId,
89
+ currentPersona: 'Wave',
90
+ tokenUsage: defaultTokenUsage,
91
+ },
92
+ updateSession: vi.fn(),
93
+ };
94
+ }
95
+
96
+ // ============================================================================
97
+ // getPendingRequests Tests
98
+ // ============================================================================
99
+
100
+ describe('getPendingRequests', () => {
101
+ beforeEach(() => vi.clearAllMocks());
102
+
103
+ it('should throw error for missing project_id', async () => {
104
+ const supabase = createMockSupabase();
105
+ const ctx = createMockContext(supabase);
106
+
107
+ await expect(getPendingRequests({}, ctx)).rejects.toThrow(ValidationError);
108
+ });
109
+
110
+ it('should throw error for invalid project_id UUID', async () => {
111
+ const supabase = createMockSupabase();
112
+ const ctx = createMockContext(supabase);
113
+
114
+ await expect(
115
+ getPendingRequests({ project_id: 'invalid' }, ctx)
116
+ ).rejects.toThrow(ValidationError);
117
+ });
118
+
119
+ it('should return empty list when no requests', async () => {
120
+ const supabase = createMockSupabase({
121
+ selectResult: { data: [], error: null },
122
+ sessionsResult: { data: [], error: null },
123
+ });
124
+ const ctx = createMockContext(supabase);
125
+
126
+ const result = await getPendingRequests(
127
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
128
+ ctx
129
+ );
130
+
131
+ expect(result.result).toMatchObject({
132
+ requests: [],
133
+ count: 0,
134
+ questions_count: 0,
135
+ });
136
+ });
137
+
138
+ it('should return pending requests', async () => {
139
+ const mockRequests = [
140
+ {
141
+ id: 'r1',
142
+ request_type: 'task',
143
+ content: 'Please do this',
144
+ session_id: null, // broadcast
145
+ acknowledged_at: null,
146
+ created_at: '2025-01-14T10:00:00Z',
147
+ },
148
+ ];
149
+
150
+ const supabase = createMockSupabase({
151
+ selectResult: { data: mockRequests, error: null },
152
+ sessionsResult: { data: [], error: null },
153
+ });
154
+ const ctx = createMockContext(supabase);
155
+
156
+ const result = await getPendingRequests(
157
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
158
+ ctx
159
+ );
160
+
161
+ expect((result.result as { count: number }).count).toBe(1);
162
+ });
163
+
164
+ it('should filter broadcast requests (session_id is null)', async () => {
165
+ const mockRequests = [
166
+ {
167
+ id: 'r1',
168
+ request_type: 'task',
169
+ content: 'Broadcast request',
170
+ session_id: null,
171
+ acknowledged_at: null,
172
+ created_at: '2025-01-14T10:00:00Z',
173
+ },
174
+ ];
175
+
176
+ const supabase = createMockSupabase({
177
+ selectResult: { data: mockRequests, error: null },
178
+ sessionsResult: { data: [], error: null },
179
+ });
180
+ const ctx = createMockContext(supabase);
181
+
182
+ const result = await getPendingRequests(
183
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
184
+ ctx
185
+ );
186
+
187
+ // Broadcast request should be included
188
+ expect((result.result as { count: number }).count).toBe(1);
189
+ });
190
+
191
+ it('should include requests targeted to current session', async () => {
192
+ const mockRequests = [
193
+ {
194
+ id: 'r1',
195
+ request_type: 'task',
196
+ content: 'Targeted request',
197
+ session_id: 'session-123',
198
+ acknowledged_at: null,
199
+ created_at: '2025-01-14T10:00:00Z',
200
+ },
201
+ ];
202
+
203
+ const supabase = createMockSupabase({
204
+ selectResult: { data: mockRequests, error: null },
205
+ sessionsResult: { data: [], error: null },
206
+ });
207
+ const ctx = createMockContext(supabase, { sessionId: 'session-123' });
208
+
209
+ const result = await getPendingRequests(
210
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
211
+ ctx
212
+ );
213
+
214
+ expect((result.result as { count: number }).count).toBe(1);
215
+ });
216
+
217
+ it('should exclude requests targeted to other sessions', async () => {
218
+ const mockRequests = [
219
+ {
220
+ id: 'r1',
221
+ request_type: 'task',
222
+ content: 'Other session request',
223
+ session_id: 'other-session',
224
+ acknowledged_at: null,
225
+ created_at: '2025-01-14T10:00:00Z',
226
+ },
227
+ ];
228
+
229
+ const supabase = createMockSupabase({
230
+ selectResult: { data: mockRequests, error: null },
231
+ sessionsResult: { data: [{ id: 'other-session' }], error: null },
232
+ });
233
+ const ctx = createMockContext(supabase, { sessionId: 'session-123' });
234
+
235
+ const result = await getPendingRequests(
236
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
237
+ ctx
238
+ );
239
+
240
+ // Should be excluded because targeted to an active other session
241
+ expect((result.result as { count: number }).count).toBe(0);
242
+ });
243
+
244
+ it('should count unanswered questions', async () => {
245
+ const mockRequests = [
246
+ {
247
+ id: 'r1',
248
+ request_type: 'question',
249
+ content: 'What is this?',
250
+ session_id: null,
251
+ acknowledged_at: null,
252
+ answered_at: null,
253
+ created_at: '2025-01-14T10:00:00Z',
254
+ },
255
+ {
256
+ id: 'r2',
257
+ request_type: 'task',
258
+ content: 'Do this',
259
+ session_id: null,
260
+ acknowledged_at: null,
261
+ created_at: '2025-01-14T11:00:00Z',
262
+ },
263
+ ];
264
+
265
+ const supabase = createMockSupabase({
266
+ selectResult: { data: mockRequests, error: null },
267
+ sessionsResult: { data: [], error: null },
268
+ });
269
+ const ctx = createMockContext(supabase);
270
+
271
+ const result = await getPendingRequests(
272
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
273
+ ctx
274
+ );
275
+
276
+ expect((result.result as { questions_count: number }).questions_count).toBe(1);
277
+ });
278
+
279
+ it('should add wait_minutes to requests', async () => {
280
+ const mockRequests = [
281
+ {
282
+ id: 'r1',
283
+ request_type: 'task',
284
+ content: 'Request',
285
+ session_id: null,
286
+ acknowledged_at: null,
287
+ created_at: new Date(Date.now() - 5 * 60000).toISOString(), // 5 minutes ago
288
+ },
289
+ ];
290
+
291
+ const supabase = createMockSupabase({
292
+ selectResult: { data: mockRequests, error: null },
293
+ sessionsResult: { data: [], error: null },
294
+ });
295
+ const ctx = createMockContext(supabase);
296
+
297
+ const result = await getPendingRequests(
298
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
299
+ ctx
300
+ );
301
+
302
+ const requests = (result.result as { requests: { wait_minutes: number }[] }).requests;
303
+ expect(requests[0].wait_minutes).toBeGreaterThanOrEqual(4); // Allow some margin
304
+ });
305
+
306
+ it('should query agent_requests table', async () => {
307
+ const supabase = createMockSupabase({
308
+ selectResult: { data: [], error: null },
309
+ sessionsResult: { data: [], error: null },
310
+ });
311
+ const ctx = createMockContext(supabase);
312
+
313
+ await getPendingRequests(
314
+ { project_id: '123e4567-e89b-12d3-a456-426614174000' },
315
+ ctx
316
+ );
317
+
318
+ expect(supabase.from).toHaveBeenCalledWith('agent_requests');
319
+ });
320
+ });
321
+
322
+ // ============================================================================
323
+ // acknowledgeRequest Tests
324
+ // ============================================================================
325
+
326
+ describe('acknowledgeRequest', () => {
327
+ beforeEach(() => vi.clearAllMocks());
328
+
329
+ it('should throw error for missing request_id', async () => {
330
+ const supabase = createMockSupabase();
331
+ const ctx = createMockContext(supabase);
332
+
333
+ await expect(acknowledgeRequest({}, ctx)).rejects.toThrow(ValidationError);
334
+ });
335
+
336
+ it('should throw error for invalid request_id UUID', async () => {
337
+ const supabase = createMockSupabase();
338
+ const ctx = createMockContext(supabase);
339
+
340
+ await expect(
341
+ acknowledgeRequest({ request_id: 'invalid' }, ctx)
342
+ ).rejects.toThrow(ValidationError);
343
+ });
344
+
345
+ it('should acknowledge request successfully', async () => {
346
+ const mockRequest = {
347
+ id: 'r1',
348
+ request_type: 'task',
349
+ content: 'Do this',
350
+ acknowledged_at: '2025-01-14T12:00:00Z',
351
+ };
352
+
353
+ const supabase = createMockSupabase({
354
+ updateResult: { data: mockRequest, error: null },
355
+ });
356
+ const ctx = createMockContext(supabase);
357
+
358
+ const result = await acknowledgeRequest(
359
+ { request_id: '123e4567-e89b-12d3-a456-426614174000' },
360
+ ctx
361
+ );
362
+
363
+ expect(result.result).toMatchObject({
364
+ success: true,
365
+ request: mockRequest,
366
+ });
367
+ });
368
+
369
+ it('should set acknowledged_at and acknowledged_by_session_id', async () => {
370
+ const supabase = createMockSupabase({
371
+ updateResult: { data: { id: 'r1' }, error: null },
372
+ });
373
+ const ctx = createMockContext(supabase, { sessionId: 'my-session' });
374
+
375
+ await acknowledgeRequest(
376
+ { request_id: '123e4567-e89b-12d3-a456-426614174000' },
377
+ ctx
378
+ );
379
+
380
+ expect(supabase.update).toHaveBeenCalledWith(
381
+ expect.objectContaining({
382
+ acknowledged_at: expect.any(String),
383
+ acknowledged_by_session_id: 'my-session',
384
+ })
385
+ );
386
+ });
387
+
388
+ it('should query agent_requests table', async () => {
389
+ const supabase = createMockSupabase({
390
+ updateResult: { data: { id: 'r1' }, error: null },
391
+ });
392
+ const ctx = createMockContext(supabase);
393
+
394
+ await acknowledgeRequest(
395
+ { request_id: '123e4567-e89b-12d3-a456-426614174000' },
396
+ ctx
397
+ );
398
+
399
+ expect(supabase.from).toHaveBeenCalledWith('agent_requests');
400
+ });
401
+
402
+ it('should throw error when update fails', async () => {
403
+ const supabase = createMockSupabase({
404
+ updateResult: { data: null, error: { message: 'Update failed' } },
405
+ });
406
+ const ctx = createMockContext(supabase);
407
+
408
+ await expect(
409
+ acknowledgeRequest({ request_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx)
410
+ ).rejects.toThrow();
411
+ });
412
+ });
413
+
414
+ // ============================================================================
415
+ // answerQuestion Tests
416
+ // ============================================================================
417
+
418
+ describe('answerQuestion', () => {
419
+ beforeEach(() => vi.clearAllMocks());
420
+
421
+ it('should throw error for missing request_id', async () => {
422
+ const supabase = createMockSupabase();
423
+ const ctx = createMockContext(supabase);
424
+
425
+ await expect(
426
+ answerQuestion({ answer: 'The answer' }, ctx)
427
+ ).rejects.toThrow(ValidationError);
428
+ });
429
+
430
+ it('should throw error for missing answer', async () => {
431
+ const supabase = createMockSupabase();
432
+ const ctx = createMockContext(supabase);
433
+
434
+ await expect(
435
+ answerQuestion({ request_id: '123e4567-e89b-12d3-a456-426614174000' }, ctx)
436
+ ).rejects.toThrow(ValidationError);
437
+ });
438
+
439
+ it('should throw error for invalid request_id UUID', async () => {
440
+ const supabase = createMockSupabase();
441
+ const ctx = createMockContext(supabase);
442
+
443
+ await expect(
444
+ answerQuestion({ request_id: 'invalid', answer: 'The answer' }, ctx)
445
+ ).rejects.toThrow(ValidationError);
446
+ });
447
+
448
+ it('should answer question successfully', async () => {
449
+ const mockRequest = {
450
+ id: 'r1',
451
+ request_type: 'question',
452
+ content: 'What is this?',
453
+ answer: 'This is the answer',
454
+ answered_at: '2025-01-14T12:00:00Z',
455
+ };
456
+
457
+ const supabase = createMockSupabase({
458
+ updateResult: { data: mockRequest, error: null },
459
+ });
460
+ const ctx = createMockContext(supabase);
461
+
462
+ const result = await answerQuestion(
463
+ {
464
+ request_id: '123e4567-e89b-12d3-a456-426614174000',
465
+ answer: 'This is the answer',
466
+ },
467
+ ctx
468
+ );
469
+
470
+ expect(result.result).toMatchObject({
471
+ success: true,
472
+ message: 'Question answered successfully',
473
+ request: mockRequest,
474
+ });
475
+ });
476
+
477
+ it('should set answer, answered_at, and acknowledgment fields', async () => {
478
+ const supabase = createMockSupabase({
479
+ updateResult: { data: { id: 'r1' }, error: null },
480
+ });
481
+ const ctx = createMockContext(supabase, { sessionId: 'my-session' });
482
+
483
+ await answerQuestion(
484
+ {
485
+ request_id: '123e4567-e89b-12d3-a456-426614174000',
486
+ answer: 'The answer to the question',
487
+ },
488
+ ctx
489
+ );
490
+
491
+ expect(supabase.update).toHaveBeenCalledWith(
492
+ expect.objectContaining({
493
+ answer: 'The answer to the question',
494
+ answered_at: expect.any(String),
495
+ acknowledged_at: expect.any(String),
496
+ acknowledged_by_session_id: 'my-session',
497
+ })
498
+ );
499
+ });
500
+
501
+ it('should query agent_requests table', async () => {
502
+ const supabase = createMockSupabase({
503
+ updateResult: { data: { id: 'r1' }, error: null },
504
+ });
505
+ const ctx = createMockContext(supabase);
506
+
507
+ await answerQuestion(
508
+ {
509
+ request_id: '123e4567-e89b-12d3-a456-426614174000',
510
+ answer: 'Answer',
511
+ },
512
+ ctx
513
+ );
514
+
515
+ expect(supabase.from).toHaveBeenCalledWith('agent_requests');
516
+ });
517
+
518
+ it('should throw error when update fails', async () => {
519
+ const supabase = createMockSupabase({
520
+ updateResult: { data: null, error: { message: 'Update failed' } },
521
+ });
522
+ const ctx = createMockContext(supabase);
523
+
524
+ await expect(
525
+ answerQuestion({
526
+ request_id: '123e4567-e89b-12d3-a456-426614174000',
527
+ answer: 'Answer',
528
+ }, ctx)
529
+ ).rejects.toThrow();
530
+ });
531
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Requests Handlers
3
+ *
4
+ * Handles user request handling:
5
+ * - get_pending_requests
6
+ * - acknowledge_request
7
+ * - answer_question
8
+ */
9
+
10
+ import type { Handler, HandlerRegistry } from './types.js';
11
+ import { validateRequired, validateUUID } from '../validators.js';
12
+
13
+ export const getPendingRequests: Handler = async (args, ctx) => {
14
+ const { project_id } = args as { project_id: string };
15
+
16
+ validateRequired(project_id, 'project_id');
17
+ validateUUID(project_id, 'project_id');
18
+
19
+ const { supabase, session } = ctx;
20
+ const currentSessionId = session.currentSessionId;
21
+
22
+ // Get active session IDs to identify orphaned questions
23
+ const { data: activeSessions } = await supabase
24
+ .from('agent_sessions')
25
+ .select('id')
26
+ .eq('project_id', project_id)
27
+ .eq('status', 'active');
28
+ const activeSessionIds = new Set((activeSessions || []).map((s) => s.id));
29
+
30
+ // Get pending requests for this project:
31
+ // - Unacknowledged requests OR unanswered questions (questions need answers, not just acknowledgment)
32
+ const { data: requests, error } = await supabase
33
+ .from('agent_requests')
34
+ .select('*')
35
+ .eq('project_id', project_id)
36
+ .or('acknowledged_at.is.null,and(request_type.eq.question,answered_at.is.null)')
37
+ .order('created_at', { ascending: false });
38
+
39
+ if (error) throw error;
40
+
41
+ // Filter to requests this agent can handle
42
+ const filteredRequests = (requests || []).filter((r) => {
43
+ // Broadcast requests (session_id is null) - anyone can handle
44
+ if (!r.session_id) return true;
45
+ // Targeted to this session
46
+ if (r.session_id === currentSessionId) return true;
47
+ // Orphaned questions (targeted session is disconnected) - any agent can answer
48
+ if (r.request_type === 'question' && !activeSessionIds.has(r.session_id)) return true;
49
+ return false;
50
+ });
51
+
52
+ // Sort questions first (highest priority) and add wait times
53
+ const now = new Date();
54
+ const sortedRequests = filteredRequests
55
+ .map((r) => ({
56
+ ...r,
57
+ wait_minutes: Math.floor((now.getTime() - new Date(r.created_at).getTime()) / 60000),
58
+ }))
59
+ .sort((a, b) => {
60
+ // Questions first, then by created_at (oldest first for urgency)
61
+ const aIsQuestion = a.request_type === 'question' && !a.answered_at;
62
+ const bIsQuestion = b.request_type === 'question' && !b.answered_at;
63
+ if (aIsQuestion && !bIsQuestion) return -1;
64
+ if (!aIsQuestion && bIsQuestion) return 1;
65
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
66
+ });
67
+
68
+ // Count unanswered questions separately
69
+ const questionsCount = sortedRequests.filter(
70
+ (r) => r.request_type === 'question' && !r.answered_at
71
+ ).length;
72
+
73
+ return {
74
+ result: {
75
+ requests: sortedRequests,
76
+ count: sortedRequests.length,
77
+ questions_count: questionsCount,
78
+ },
79
+ };
80
+ };
81
+
82
+ export const acknowledgeRequest: Handler = async (args, ctx) => {
83
+ const { request_id } = args as { request_id: string };
84
+
85
+ validateRequired(request_id, 'request_id');
86
+ validateUUID(request_id, 'request_id');
87
+
88
+ const { supabase, session } = ctx;
89
+
90
+ const { data: request, error } = await supabase
91
+ .from('agent_requests')
92
+ .update({
93
+ acknowledged_at: new Date().toISOString(),
94
+ acknowledged_by_session_id: session.currentSessionId,
95
+ })
96
+ .eq('id', request_id)
97
+ .select()
98
+ .single();
99
+
100
+ if (error) throw error;
101
+
102
+ return {
103
+ result: {
104
+ success: true,
105
+ request,
106
+ },
107
+ };
108
+ };
109
+
110
+ export const answerQuestion: Handler = async (args, ctx) => {
111
+ const { request_id, answer } = args as { request_id: string; answer: string };
112
+
113
+ validateRequired(request_id, 'request_id');
114
+ validateRequired(answer, 'answer');
115
+ validateUUID(request_id, 'request_id');
116
+
117
+ const { supabase, session } = ctx;
118
+
119
+ // Update the request with the answer
120
+ const { data: request, error } = await supabase
121
+ .from('agent_requests')
122
+ .update({
123
+ answer,
124
+ answered_at: new Date().toISOString(),
125
+ acknowledged_at: new Date().toISOString(),
126
+ acknowledged_by_session_id: session.currentSessionId,
127
+ })
128
+ .eq('id', request_id)
129
+ .select()
130
+ .single();
131
+
132
+ if (error) throw error;
133
+
134
+ return {
135
+ result: {
136
+ success: true,
137
+ message: 'Question answered successfully',
138
+ request,
139
+ },
140
+ };
141
+ };
142
+
143
+ /**
144
+ * Requests handlers registry
145
+ */
146
+ export const requestHandlers: HandlerRegistry = {
147
+ get_pending_requests: getPendingRequests,
148
+ acknowledge_request: acknowledgeRequest,
149
+ answer_question: answerQuestion,
150
+ };