@aiconnect/agentjobs-mcp 1.0.9 → 1.2.0
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/.env.example +7 -0
- package/README.md +67 -0
- package/build/config.js +10 -3
- package/build/config.test.js +22 -0
- package/build/debug.js +86 -0
- package/build/index.js +71 -7
- package/build/lib/agentJobsClient.js +90 -0
- package/build/test-tools.js +53 -0
- package/build/tools/cancel_job.js +35 -50
- package/build/tools/create_job.js +49 -69
- package/build/tools/get_context.js +61 -0
- package/build/tools/get_job.js +30 -58
- package/build/tools/get_job_type.js +40 -56
- package/build/tools/get_jobs_stats.js +47 -0
- package/build/tools/list_jobs.js +44 -58
- package/build/utils/debugger.js +74 -0
- package/build/utils/formatters.js +516 -44
- package/build/utils/formatters.test.js +629 -0
- package/build/utils/schemas.js +24 -0
- package/build/utils/schemas.test.js +95 -0
- package/build/utils/version.js +12 -0
- package/build/utils/version.test.js +9 -0
- package/docs/agent-jobs-api.md +41 -0
- package/docs/debug-guide.md +203 -0
- package/package.json +13 -4
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { formatJobDetails, formatJobList, formatJobStats, formatJobTypeDetails, formatJobTypeSummary } from './formatters.js';
|
|
3
|
+
describe('formatJobDetails', () => {
|
|
4
|
+
const fullJob = {
|
|
5
|
+
job_id: 'job_123',
|
|
6
|
+
job_type_id: 'type_abc',
|
|
7
|
+
org_id: 'org_xyz',
|
|
8
|
+
channel_code: 'ch_def',
|
|
9
|
+
chat_id: 'chat_456',
|
|
10
|
+
job_status: 'completed',
|
|
11
|
+
result: 'Success',
|
|
12
|
+
created_at: '2023-01-01T10:00:00.000Z',
|
|
13
|
+
updated_at: '2023-01-01T10:15:00.000Z',
|
|
14
|
+
scheduled_at: '2023-01-01T09:55:00.000Z',
|
|
15
|
+
last_task_created_at: '2023-01-01T10:10:00.000Z',
|
|
16
|
+
tags: 'tag1, tag2',
|
|
17
|
+
execution_log: ['Log entry 1', 'Log entry 2'],
|
|
18
|
+
tasks: [{ task_id: 'task_789', created_at: '2023-01-01T10:05:00.000Z' }],
|
|
19
|
+
flags: {
|
|
20
|
+
is_new_channel: true,
|
|
21
|
+
has_human_reply: false,
|
|
22
|
+
first_reply_at: '2023-01-01T10:02:00.000Z',
|
|
23
|
+
ignore_cooldown: true,
|
|
24
|
+
},
|
|
25
|
+
channel_data: {
|
|
26
|
+
platform: 'test_platform',
|
|
27
|
+
name: 'Test Channel',
|
|
28
|
+
},
|
|
29
|
+
job_config: {
|
|
30
|
+
max_task_retries: 3,
|
|
31
|
+
start_prompt: 'Initial prompt',
|
|
32
|
+
},
|
|
33
|
+
params: { key: 'value' },
|
|
34
|
+
};
|
|
35
|
+
it('should format a full job object correctly', () => {
|
|
36
|
+
const result = formatJobDetails(fullJob);
|
|
37
|
+
expect(result).toContain('Job ID: job_123');
|
|
38
|
+
expect(result).toContain('Status: completed');
|
|
39
|
+
expect(result).toContain('Result: Success');
|
|
40
|
+
expect(result).toContain('Tags: tag1, tag2');
|
|
41
|
+
expect(result).toContain('Total Tasks: 1');
|
|
42
|
+
expect(result).toContain('Retries Used: 0 / 3 (remaining: 3)');
|
|
43
|
+
expect(result).toContain('Duration: 20m 0s');
|
|
44
|
+
});
|
|
45
|
+
it('should handle minimal job object', () => {
|
|
46
|
+
const minimalJob = {
|
|
47
|
+
job_id: 'job_min',
|
|
48
|
+
job_type_id: 'type_min',
|
|
49
|
+
org_id: 'org_min',
|
|
50
|
+
channel_code: 'ch_min',
|
|
51
|
+
job_status: 'running',
|
|
52
|
+
created_at: new Date().toISOString(),
|
|
53
|
+
updated_at: new Date().toISOString(),
|
|
54
|
+
};
|
|
55
|
+
const result = formatJobDetails(minimalJob);
|
|
56
|
+
expect(result).toContain('Job ID: job_min');
|
|
57
|
+
expect(result).toContain('Status: running');
|
|
58
|
+
expect(result).toContain('Result: n/a');
|
|
59
|
+
expect(result).toContain('Total Tasks: 0');
|
|
60
|
+
});
|
|
61
|
+
it('should handle dates as numbers (timestamps)', () => {
|
|
62
|
+
const jobWithTimestamps = {
|
|
63
|
+
...fullJob,
|
|
64
|
+
created_at: new Date(fullJob.created_at).getTime(),
|
|
65
|
+
updated_at: new Date(fullJob.updated_at).getTime(),
|
|
66
|
+
};
|
|
67
|
+
const result = formatJobDetails(jobWithTimestamps);
|
|
68
|
+
expect(result).toContain(`Created At: ${new Date(jobWithTimestamps.created_at).toISOString()}`);
|
|
69
|
+
expect(result).toContain(`Updated At: ${new Date(jobWithTimestamps.updated_at).toISOString()}`);
|
|
70
|
+
});
|
|
71
|
+
it('should fall back to JSON.stringify for invalid job object', () => {
|
|
72
|
+
const invalidJob = { job_id: '123' }; // Missing required fields
|
|
73
|
+
const result = formatJobDetails(invalidJob);
|
|
74
|
+
expect(result).toContain('"job_id": "123"');
|
|
75
|
+
expect(result).toContain('Job Details (raw):');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('formatJobStats', () => {
|
|
79
|
+
it('should return correct percentages when total is greater than 0', () => {
|
|
80
|
+
const stats = {
|
|
81
|
+
status: {
|
|
82
|
+
completed: 10,
|
|
83
|
+
running: 5,
|
|
84
|
+
failed: 2,
|
|
85
|
+
canceled: 1,
|
|
86
|
+
waiting: 1,
|
|
87
|
+
scheduled: 1,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
const filters = {};
|
|
91
|
+
const result = formatJobStats(stats, filters);
|
|
92
|
+
expect(result).toContain('Completed: 10 jobs (50.0%)');
|
|
93
|
+
expect(result).toContain('Running: 5 jobs (25.0%)');
|
|
94
|
+
expect(result).toContain('Failed: 2 jobs (10.0%)');
|
|
95
|
+
expect(result).toContain('Canceled: 1 jobs (5.0%)');
|
|
96
|
+
expect(result).toContain('Waiting: 1 jobs (5.0%)');
|
|
97
|
+
expect(result).toContain('Scheduled: 1 jobs (5.0%)');
|
|
98
|
+
expect(result).toContain('Total Jobs: 20');
|
|
99
|
+
});
|
|
100
|
+
it('should return 0.0% for all statuses when there are no jobs', () => {
|
|
101
|
+
const stats = {
|
|
102
|
+
status: {
|
|
103
|
+
completed: 0,
|
|
104
|
+
running: 0,
|
|
105
|
+
failed: 0,
|
|
106
|
+
canceled: 0,
|
|
107
|
+
waiting: 0,
|
|
108
|
+
scheduled: 0,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const filters = {};
|
|
112
|
+
const result = formatJobStats(stats, filters);
|
|
113
|
+
expect(result).toContain('Completed: 0 jobs (0.0%)');
|
|
114
|
+
expect(result).toContain('Running: 0 jobs (0.0%)');
|
|
115
|
+
expect(result).toContain('Failed: 0 jobs (0.0%)');
|
|
116
|
+
expect(result).toContain('Canceled: 0 jobs (0.0%)');
|
|
117
|
+
expect(result).toContain('Waiting: 0 jobs (0.0%)');
|
|
118
|
+
expect(result).toContain('Scheduled: 0 jobs (0.0%)');
|
|
119
|
+
expect(result).toContain('Total Jobs: 0');
|
|
120
|
+
});
|
|
121
|
+
it('should handle null filters gracefully', () => {
|
|
122
|
+
const stats = {
|
|
123
|
+
status: {
|
|
124
|
+
completed: 10,
|
|
125
|
+
running: 5,
|
|
126
|
+
failed: 2,
|
|
127
|
+
canceled: 1,
|
|
128
|
+
waiting: 1,
|
|
129
|
+
scheduled: 1,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
const filters = null;
|
|
133
|
+
const result = formatJobStats(stats, filters);
|
|
134
|
+
expect(result).toContain('Period: All time');
|
|
135
|
+
});
|
|
136
|
+
describe('header — Scheduled date range', () => {
|
|
137
|
+
const stats = {
|
|
138
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
139
|
+
};
|
|
140
|
+
it('renders both bounds when scheduled_at_gte and scheduled_at_lte are provided', () => {
|
|
141
|
+
const result = formatJobStats(stats, {
|
|
142
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
143
|
+
scheduled_at_lte: '2026-05-01T00:00:00Z',
|
|
144
|
+
});
|
|
145
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → 2026-05-01T00:00:00Z');
|
|
146
|
+
expect(result).not.toContain('Period: All time');
|
|
147
|
+
});
|
|
148
|
+
it('renders "(open)" upper bound when only scheduled_at_gte is provided', () => {
|
|
149
|
+
const result = formatJobStats(stats, {
|
|
150
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
151
|
+
});
|
|
152
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → (open)');
|
|
153
|
+
});
|
|
154
|
+
it('renders "(open)" lower bound when only scheduled_at_lte is provided', () => {
|
|
155
|
+
const result = formatJobStats(stats, {
|
|
156
|
+
scheduled_at_lte: '2026-05-01T00:00:00Z',
|
|
157
|
+
});
|
|
158
|
+
expect(result).toContain('Scheduled: (open) → 2026-05-01T00:00:00Z');
|
|
159
|
+
});
|
|
160
|
+
it('omits the Scheduled range line entirely when no scheduled_at bound is provided', () => {
|
|
161
|
+
const result = formatJobStats(stats, { status: 'failed' });
|
|
162
|
+
// The Status Breakdown contains "⏰ Scheduled:"; we only want to assert
|
|
163
|
+
// the header-range form ("Scheduled: <bound> → ...") is absent.
|
|
164
|
+
expect(result).not.toMatch(/(^|\n)Scheduled: .+→/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
describe('header — Created date range', () => {
|
|
168
|
+
const stats = {
|
|
169
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
170
|
+
};
|
|
171
|
+
it('renders both bounds when created_at_gte and created_at_lte are provided', () => {
|
|
172
|
+
const result = formatJobStats(stats, {
|
|
173
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
174
|
+
created_at_lte: '2026-04-30T00:00:00Z',
|
|
175
|
+
});
|
|
176
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → 2026-04-30T00:00:00Z');
|
|
177
|
+
});
|
|
178
|
+
it('renders "(open)" upper bound when only created_at_gte is provided', () => {
|
|
179
|
+
const result = formatJobStats(stats, {
|
|
180
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
181
|
+
});
|
|
182
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → (open)');
|
|
183
|
+
});
|
|
184
|
+
it('renders "(open)" lower bound when only created_at_lte is provided', () => {
|
|
185
|
+
const result = formatJobStats(stats, {
|
|
186
|
+
created_at_lte: '2026-04-30T00:00:00Z',
|
|
187
|
+
});
|
|
188
|
+
expect(result).toContain('Created: (open) → 2026-04-30T00:00:00Z');
|
|
189
|
+
});
|
|
190
|
+
it('omits the Created line entirely when no created_at bound is provided', () => {
|
|
191
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
192
|
+
expect(result).not.toContain('Created:');
|
|
193
|
+
});
|
|
194
|
+
it('renders both Scheduled and Created lines when both ranges are filtered', () => {
|
|
195
|
+
const result = formatJobStats(stats, {
|
|
196
|
+
scheduled_at_gte: '2026-04-30T00:00:00Z',
|
|
197
|
+
created_at_gte: '2026-04-01T00:00:00Z',
|
|
198
|
+
});
|
|
199
|
+
expect(result).toContain('Scheduled: 2026-04-30T00:00:00Z → (open)');
|
|
200
|
+
expect(result).toContain('Created: 2026-04-01T00:00:00Z → (open)');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
describe('header — Period: All time fallback', () => {
|
|
204
|
+
const stats = {
|
|
205
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
206
|
+
};
|
|
207
|
+
it('renders "Period: All time" when no date filter is provided', () => {
|
|
208
|
+
const result = formatJobStats(stats, {});
|
|
209
|
+
expect(result).toContain('Period: All time');
|
|
210
|
+
});
|
|
211
|
+
it('does not render "Period: All time" when scheduled_at_gte is set', () => {
|
|
212
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
213
|
+
expect(result).not.toContain('Period: All time');
|
|
214
|
+
});
|
|
215
|
+
it('does not render "Period: All time" when created_at_lte is set', () => {
|
|
216
|
+
const result = formatJobStats(stats, { created_at_lte: '2026-04-30T00:00:00Z' });
|
|
217
|
+
expect(result).not.toContain('Period: All time');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
describe('header — Filters section', () => {
|
|
221
|
+
const stats = {
|
|
222
|
+
status: { completed: 1, running: 0, failed: 0, canceled: 0, waiting: 0, scheduled: 0 },
|
|
223
|
+
};
|
|
224
|
+
it('renders a single line when one non-date filter is supplied', () => {
|
|
225
|
+
const result = formatJobStats(stats, { status: 'failed' });
|
|
226
|
+
expect(result).toContain('Filters:');
|
|
227
|
+
expect(result).toContain('- status: failed');
|
|
228
|
+
});
|
|
229
|
+
it('renders multiple lines when multiple non-date filters are supplied', () => {
|
|
230
|
+
const result = formatJobStats(stats, {
|
|
231
|
+
status: 'failed',
|
|
232
|
+
job_type_id: 'woba-supplier-ai-batch',
|
|
233
|
+
});
|
|
234
|
+
expect(result).toContain('- status: failed');
|
|
235
|
+
expect(result).toContain('- job_type_id: woba-supplier-ai-batch');
|
|
236
|
+
});
|
|
237
|
+
it('omits the Filters section entirely when only date filters are supplied', () => {
|
|
238
|
+
const result = formatJobStats(stats, { scheduled_at_gte: '2026-04-30T00:00:00Z' });
|
|
239
|
+
expect(result).not.toContain('Filters:');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
describe('formatJobList', () => {
|
|
244
|
+
const sampleJob = {
|
|
245
|
+
job_id: 'job_1',
|
|
246
|
+
channel_code: 'ch_1',
|
|
247
|
+
created_at: '2026-04-30T00:00:00.000Z',
|
|
248
|
+
updated_at: '2026-04-30T00:10:00.000Z',
|
|
249
|
+
scheduled_at: '2026-04-30T00:00:00.000Z',
|
|
250
|
+
job_status: 'completed',
|
|
251
|
+
result: 'ok',
|
|
252
|
+
job_type_id: 'type_1',
|
|
253
|
+
};
|
|
254
|
+
it('renders the four-field footer on first page with more pages available', () => {
|
|
255
|
+
const jobs = Array.from({ length: 20 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
256
|
+
const meta = { count: 20, limit: 20, total: 40 };
|
|
257
|
+
const result = formatJobList(jobs, meta, 0);
|
|
258
|
+
expect(result).toContain('Returned: 20 | Total matching: 40 | Has more: true | Next offset: 20');
|
|
259
|
+
expect(result).not.toContain('Page:');
|
|
260
|
+
expect(result).not.toContain('Total Jobs:');
|
|
261
|
+
});
|
|
262
|
+
it('renders Has more: false and Next offset: null on the last page', () => {
|
|
263
|
+
const jobs = Array.from({ length: 20 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
264
|
+
const meta = { count: 20, limit: 20, total: 40 };
|
|
265
|
+
const result = formatJobList(jobs, meta, 20);
|
|
266
|
+
expect(result).toContain('Returned: 20 | Total matching: 40 | Has more: false | Next offset: null');
|
|
267
|
+
});
|
|
268
|
+
it('handles a partial last page (count < limit)', () => {
|
|
269
|
+
const jobs = Array.from({ length: 5 }, (_, i) => ({ ...sampleJob, job_id: `job_${i}` }));
|
|
270
|
+
const meta = { count: 5, limit: 20, total: 25 };
|
|
271
|
+
const result = formatJobList(jobs, meta, 20);
|
|
272
|
+
expect(result).toContain('Returned: 5 | Total matching: 25 | Has more: false | Next offset: null');
|
|
273
|
+
});
|
|
274
|
+
it('renders the footer on an empty result on the first page', () => {
|
|
275
|
+
const meta = { count: 0, limit: 20, total: 0 };
|
|
276
|
+
const result = formatJobList([], meta, 0);
|
|
277
|
+
expect(result).toContain('Found 0 jobs.');
|
|
278
|
+
expect(result).toContain('Returned: 0 | Total matching: 0 | Has more: false | Next offset: null');
|
|
279
|
+
expect(result).not.toContain('No jobs found for the given criteria.');
|
|
280
|
+
});
|
|
281
|
+
it('renders Total matching reflecting real total when offset overflows the result set', () => {
|
|
282
|
+
const meta = { count: 0, limit: 20, total: 40 };
|
|
283
|
+
const result = formatJobList([], meta, 100);
|
|
284
|
+
expect(result).toContain('Found 0 jobs.');
|
|
285
|
+
expect(result).toContain('Returned: 0 | Total matching: 40 | Has more: false | Next offset: null');
|
|
286
|
+
});
|
|
287
|
+
describe('fail-fast on missing meta fields', () => {
|
|
288
|
+
it('throws when meta is null', () => {
|
|
289
|
+
expect(() => formatJobList([], null, 0)).toThrow(/meta is required/);
|
|
290
|
+
});
|
|
291
|
+
it('throws when meta.total is missing', () => {
|
|
292
|
+
expect(() => formatJobList([], { count: 0, limit: 20 }, 0)).toThrow(/meta is required.*count.*limit.*total/);
|
|
293
|
+
});
|
|
294
|
+
it('throws when meta.count is non-numeric', () => {
|
|
295
|
+
expect(() => formatJobList([], { count: 'oops', limit: 20, total: 0 }, 0)).toThrow(/meta is required/);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
describe('formatJobTypeDetails', () => {
|
|
300
|
+
const fullJobType = {
|
|
301
|
+
id: 'follow-up-v2',
|
|
302
|
+
name: 'Follow Up by DM',
|
|
303
|
+
org_id: 'acme-co',
|
|
304
|
+
version: 2,
|
|
305
|
+
visibility: 'private',
|
|
306
|
+
active: true,
|
|
307
|
+
description: 'Automated follow-up to contacts who haven\'t replied within 24h. Supports templated prompts and throttling.',
|
|
308
|
+
default_config: {
|
|
309
|
+
profile_id: 'default-bot',
|
|
310
|
+
max_follow_ups: 3,
|
|
311
|
+
max_task_retries: 2,
|
|
312
|
+
task_retry_interval: 30,
|
|
313
|
+
max_time_to_complete: 180,
|
|
314
|
+
failure_cooldown_minutes: 120,
|
|
315
|
+
start_prompt: 'Hello! Just circling back on our last conversation...'
|
|
316
|
+
},
|
|
317
|
+
params_schema: {
|
|
318
|
+
type: 'object',
|
|
319
|
+
required: ['recipient_id', 'initial_message'],
|
|
320
|
+
properties: {
|
|
321
|
+
recipient_id: { type: 'string', description: 'User ID of the recipient' },
|
|
322
|
+
initial_message: { type: 'string', description: 'Initial text to send to the recipient' },
|
|
323
|
+
locale: { type: 'string', default: 'en-US', description: 'Locale for message formatting' },
|
|
324
|
+
throttle_minutes: { type: 'number', description: 'Min minutes between attempts' },
|
|
325
|
+
metadata: { type: 'object', description: 'Free-form context data' }
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
tags: 'outreach, dm, v2',
|
|
329
|
+
created_at: '2025-08-02T14:03:28.120Z',
|
|
330
|
+
updated_at: '2025-08-12T09:22:01.553Z'
|
|
331
|
+
};
|
|
332
|
+
it('should format a full job type object correctly', () => {
|
|
333
|
+
const result = formatJobTypeDetails(fullJobType);
|
|
334
|
+
expect(result).toContain('ID: follow-up-v2');
|
|
335
|
+
expect(result).toContain('Name: Follow Up by DM');
|
|
336
|
+
expect(result).toContain('Version: 2');
|
|
337
|
+
expect(result).toContain('Visibility: private');
|
|
338
|
+
expect(result).toContain('Active: yes');
|
|
339
|
+
expect(result).toContain('Profile ID: default-bot');
|
|
340
|
+
expect(result).toContain('Max Follow-ups: 3');
|
|
341
|
+
expect(result).toContain('Max Task Retries: 2');
|
|
342
|
+
expect(result).toContain('Task Retry Interval: 30 min');
|
|
343
|
+
expect(result).toContain('retries up to 2 every 30 min');
|
|
344
|
+
expect(result).toContain('window 180 min');
|
|
345
|
+
expect(result).toContain('cooldown 120 min');
|
|
346
|
+
expect(result).toContain('Type: object | Required: 2 | Properties: 5');
|
|
347
|
+
expect(result).toContain('recipient_id: string (required)');
|
|
348
|
+
expect(result).toContain('locale: string — Locale for message formatting — Defaults to "en-US"');
|
|
349
|
+
expect(result).toContain('Tags: outreach, dm, v2');
|
|
350
|
+
});
|
|
351
|
+
it('should handle minimal job type object', () => {
|
|
352
|
+
const minimalJobType = {
|
|
353
|
+
id: 'minimal',
|
|
354
|
+
name: 'Minimal Type',
|
|
355
|
+
org_id: 'org-min',
|
|
356
|
+
created_at: new Date().toISOString(),
|
|
357
|
+
updated_at: new Date().toISOString()
|
|
358
|
+
};
|
|
359
|
+
const result = formatJobTypeDetails(minimalJobType);
|
|
360
|
+
expect(result).toContain('ID: minimal');
|
|
361
|
+
expect(result).toContain('Name: Minimal Type');
|
|
362
|
+
expect(result).toContain('Org ID: org-min');
|
|
363
|
+
expect(result).not.toContain('Version:');
|
|
364
|
+
expect(result).not.toContain('Description:');
|
|
365
|
+
});
|
|
366
|
+
it('should handle missing optional fields gracefully', () => {
|
|
367
|
+
const jobTypeNoConfig = {
|
|
368
|
+
id: 'no-config',
|
|
369
|
+
name: 'No Config Type',
|
|
370
|
+
org_id: 'org-test',
|
|
371
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
372
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
373
|
+
};
|
|
374
|
+
const result = formatJobTypeDetails(jobTypeNoConfig, { showEmptySections: true });
|
|
375
|
+
expect(result).toContain('Default Config:');
|
|
376
|
+
expect(result).toContain('n/a');
|
|
377
|
+
expect(result).toContain('Params Schema:');
|
|
378
|
+
});
|
|
379
|
+
it('should truncate long strings with ellipsis', () => {
|
|
380
|
+
const longDescription = 'A'.repeat(500);
|
|
381
|
+
const longPrompt = 'B'.repeat(600);
|
|
382
|
+
const jobTypeWithLongText = {
|
|
383
|
+
id: 'long-text',
|
|
384
|
+
name: 'Long Text Type',
|
|
385
|
+
org_id: 'org-test',
|
|
386
|
+
description: longDescription,
|
|
387
|
+
default_config: {
|
|
388
|
+
start_prompt: longPrompt
|
|
389
|
+
},
|
|
390
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
391
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
392
|
+
};
|
|
393
|
+
const result = formatJobTypeDetails(jobTypeWithLongText);
|
|
394
|
+
expect(result).toContain('A'.repeat(400) + '…');
|
|
395
|
+
expect(result).toContain('B'.repeat(500) + '…');
|
|
396
|
+
});
|
|
397
|
+
it('should normalize date fields (ms to ISO)', () => {
|
|
398
|
+
const jobTypeWithTimestamps = {
|
|
399
|
+
id: 'timestamps',
|
|
400
|
+
name: 'Timestamp Type',
|
|
401
|
+
org_id: 'org-test',
|
|
402
|
+
created_at: 1704067200000, // 2024-01-01T00:00:00.000Z
|
|
403
|
+
updated_at: 1704153600000 // 2024-01-02T00:00:00.000Z
|
|
404
|
+
};
|
|
405
|
+
const result = formatJobTypeDetails(jobTypeWithTimestamps);
|
|
406
|
+
expect(result).toContain('Created At: 2024-01-01T00:00:00.000Z');
|
|
407
|
+
expect(result).toContain('Updated At: 2024-01-02T00:00:00.000Z');
|
|
408
|
+
});
|
|
409
|
+
it('should handle tags as CSV string', () => {
|
|
410
|
+
const jobTypeWithCSVTags = {
|
|
411
|
+
id: 'csv-tags',
|
|
412
|
+
name: 'CSV Tags Type',
|
|
413
|
+
org_id: 'org-test',
|
|
414
|
+
tags: 'tag1, tag2 , tag3',
|
|
415
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
416
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
417
|
+
};
|
|
418
|
+
const result = formatJobTypeDetails(jobTypeWithCSVTags);
|
|
419
|
+
expect(result).toContain('Tags: tag1, tag2, tag3');
|
|
420
|
+
});
|
|
421
|
+
it('should handle tags as array', () => {
|
|
422
|
+
const jobTypeWithArrayTags = {
|
|
423
|
+
id: 'array-tags',
|
|
424
|
+
name: 'Array Tags Type',
|
|
425
|
+
org_id: 'org-test',
|
|
426
|
+
tags: ['tag1', 'tag2', 'tag3'],
|
|
427
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
428
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
429
|
+
};
|
|
430
|
+
const result = formatJobTypeDetails(jobTypeWithArrayTags);
|
|
431
|
+
expect(result).toContain('Tags: tag1, tag2, tag3');
|
|
432
|
+
});
|
|
433
|
+
it('should summarize large schemas with "+N more" indication', () => {
|
|
434
|
+
const properties = {};
|
|
435
|
+
for (let i = 1; i <= 30; i++) {
|
|
436
|
+
properties[`prop${i}`] = { type: 'string', description: `Property ${i}` };
|
|
437
|
+
}
|
|
438
|
+
const jobTypeWithLargeSchema = {
|
|
439
|
+
id: 'large-schema',
|
|
440
|
+
name: 'Large Schema Type',
|
|
441
|
+
org_id: 'org-test',
|
|
442
|
+
params_schema: {
|
|
443
|
+
type: 'object',
|
|
444
|
+
required: ['prop1', 'prop2'],
|
|
445
|
+
properties
|
|
446
|
+
},
|
|
447
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
448
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
449
|
+
};
|
|
450
|
+
const result = formatJobTypeDetails(jobTypeWithLargeSchema);
|
|
451
|
+
expect(result).toContain('Properties: 30');
|
|
452
|
+
expect(result).toContain('+18 more…');
|
|
453
|
+
expect(result).toContain('prop1: string (required)');
|
|
454
|
+
expect(result).toContain('prop12: string');
|
|
455
|
+
});
|
|
456
|
+
it('should return raw JSON on parse failure', () => {
|
|
457
|
+
const invalidJobType = { notAValidField: 123 };
|
|
458
|
+
const result = formatJobTypeDetails(invalidJobType);
|
|
459
|
+
expect(result).toContain('Job Type Details (raw):');
|
|
460
|
+
expect(result).toContain('"notAValidField": 123');
|
|
461
|
+
});
|
|
462
|
+
it('should respect formatter options', () => {
|
|
463
|
+
const result = formatJobTypeDetails(fullJobType, {
|
|
464
|
+
includeSchema: false,
|
|
465
|
+
renderAsMarkdown: false
|
|
466
|
+
});
|
|
467
|
+
expect(result).not.toContain('## Job Type Details');
|
|
468
|
+
expect(result).toContain('Job Type Details\n===========');
|
|
469
|
+
expect(result).not.toContain('Params Schema:');
|
|
470
|
+
});
|
|
471
|
+
it('should handle custom truncation limits', () => {
|
|
472
|
+
const longJobType = {
|
|
473
|
+
id: 'custom-truncate',
|
|
474
|
+
name: 'Custom Truncate',
|
|
475
|
+
org_id: 'org-test',
|
|
476
|
+
description: 'X'.repeat(100),
|
|
477
|
+
default_config: {
|
|
478
|
+
start_prompt: 'Y'.repeat(100)
|
|
479
|
+
},
|
|
480
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
481
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
482
|
+
};
|
|
483
|
+
const result = formatJobTypeDetails(longJobType, {
|
|
484
|
+
truncate: {
|
|
485
|
+
description: 50,
|
|
486
|
+
startPrompt: 30
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
expect(result).toContain('X'.repeat(50) + '…');
|
|
490
|
+
expect(result).toContain('Y'.repeat(30) + '…');
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
describe('formatJobTypeSummary', () => {
|
|
494
|
+
it('should format a complete job type summary', () => {
|
|
495
|
+
const jobType = {
|
|
496
|
+
id: 'summary-test',
|
|
497
|
+
name: 'Summary Test Type',
|
|
498
|
+
org_id: 'org-test',
|
|
499
|
+
active: true,
|
|
500
|
+
default_config: {
|
|
501
|
+
max_task_retries: 3,
|
|
502
|
+
task_retry_interval: 15,
|
|
503
|
+
max_time_to_complete: 60,
|
|
504
|
+
failure_cooldown_minutes: 30
|
|
505
|
+
},
|
|
506
|
+
params_schema: {
|
|
507
|
+
type: 'object',
|
|
508
|
+
required: ['field1', 'field2'],
|
|
509
|
+
properties: {
|
|
510
|
+
field1: { type: 'string' },
|
|
511
|
+
field2: { type: 'number' },
|
|
512
|
+
field3: { type: 'boolean' }
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
516
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
517
|
+
};
|
|
518
|
+
const result = formatJobTypeSummary(jobType);
|
|
519
|
+
expect(result).toContain('ID: summary-test');
|
|
520
|
+
expect(result).toContain('Name: Summary Test Type');
|
|
521
|
+
expect(result).toContain('Active: yes');
|
|
522
|
+
expect(result).toContain('Retries: 3 every 15 min');
|
|
523
|
+
expect(result).toContain('Max Time: 60 min');
|
|
524
|
+
expect(result).toContain('Cooldown: 30 min');
|
|
525
|
+
expect(result).toContain('Params: required=2, props=3');
|
|
526
|
+
});
|
|
527
|
+
it('should handle minimal job type summary', () => {
|
|
528
|
+
const minimalJobType = {
|
|
529
|
+
id: 'minimal',
|
|
530
|
+
name: 'Minimal',
|
|
531
|
+
org_id: 'org-test',
|
|
532
|
+
created_at: '2025-01-01T00:00:00Z',
|
|
533
|
+
updated_at: '2025-01-01T00:00:00Z'
|
|
534
|
+
};
|
|
535
|
+
const result = formatJobTypeSummary(minimalJobType);
|
|
536
|
+
expect(result).toContain('ID: minimal');
|
|
537
|
+
expect(result).toContain('Name: Minimal');
|
|
538
|
+
expect(result).toContain('Active: n/a');
|
|
539
|
+
expect(result).toContain('Retries: n/a');
|
|
540
|
+
expect(result).toContain('Max Time: n/a');
|
|
541
|
+
expect(result).toContain('Cooldown: n/a');
|
|
542
|
+
expect(result).toContain('Params: n/a');
|
|
543
|
+
});
|
|
544
|
+
it('should return raw JSON for invalid job type', () => {
|
|
545
|
+
const invalidJobType = { invalid: true };
|
|
546
|
+
const result = formatJobTypeSummary(invalidJobType);
|
|
547
|
+
expect(result).toContain('"invalid": true');
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
import { formatContext } from './formatters.js';
|
|
551
|
+
describe('formatContext', () => {
|
|
552
|
+
const localConfig = {
|
|
553
|
+
org_id: 'woba',
|
|
554
|
+
timezone: 'America/Sao_Paulo',
|
|
555
|
+
api_url: 'https://api.aiconnect.cloud/api/v0',
|
|
556
|
+
server_version: '0.4.2'
|
|
557
|
+
};
|
|
558
|
+
it('formats happy path with two job types', () => {
|
|
559
|
+
const result = formatContext({
|
|
560
|
+
localConfig,
|
|
561
|
+
total: 2,
|
|
562
|
+
jobTypes: [
|
|
563
|
+
{ id: 'billing-followup', name: 'Billing Follow-up', description: 'Triage de cobrança', emoji: '💳' },
|
|
564
|
+
{ id: 'support-triage', name: 'Support Triage', description: 'Roteamento de suporte', emoji: '🛠️' }
|
|
565
|
+
]
|
|
566
|
+
});
|
|
567
|
+
expect(result).toContain('Context:');
|
|
568
|
+
expect(result).toContain('Org ID: woba');
|
|
569
|
+
expect(result).toContain('Timezone: America/Sao_Paulo');
|
|
570
|
+
expect(result).toContain('API URL: https://api.aiconnect.cloud/api/v0');
|
|
571
|
+
expect(result).toContain('Server version: 0.4.2');
|
|
572
|
+
expect(result).toContain('Job types available (2):');
|
|
573
|
+
expect(result).toContain('billing-followup');
|
|
574
|
+
expect(result).toContain('💳');
|
|
575
|
+
expect(result).toContain('— Triage de cobrança');
|
|
576
|
+
});
|
|
577
|
+
it('handles zero job types', () => {
|
|
578
|
+
const result = formatContext({ localConfig, total: 0, jobTypes: [] });
|
|
579
|
+
expect(result).toContain('Job types available (0):');
|
|
580
|
+
expect(result).toContain('(no job types registered for this org)');
|
|
581
|
+
});
|
|
582
|
+
it('renders error line when jobTypesError present', () => {
|
|
583
|
+
const result = formatContext({
|
|
584
|
+
localConfig,
|
|
585
|
+
jobTypesError: 'API Error (500): Internal Server Error'
|
|
586
|
+
});
|
|
587
|
+
expect(result).toContain('Context:');
|
|
588
|
+
expect(result).toContain('Org ID: woba');
|
|
589
|
+
expect(result).toContain('Job types: unavailable (error: API Error (500): Internal Server Error)');
|
|
590
|
+
expect(result).not.toContain('Job types available');
|
|
591
|
+
});
|
|
592
|
+
it('preserves alignment when emoji missing', () => {
|
|
593
|
+
const result = formatContext({
|
|
594
|
+
localConfig,
|
|
595
|
+
total: 2,
|
|
596
|
+
jobTypes: [
|
|
597
|
+
{ id: 'with-emoji', name: 'With Emoji', emoji: '✅' },
|
|
598
|
+
{ id: 'no-emoji', name: 'No Emoji' }
|
|
599
|
+
]
|
|
600
|
+
});
|
|
601
|
+
const lines = result.split('\n');
|
|
602
|
+
const withEmojiLine = lines.find((l) => l.includes('with-emoji'));
|
|
603
|
+
const noEmojiLine = lines.find((l) => l.includes('no-emoji'));
|
|
604
|
+
const idxWith = withEmojiLine.indexOf('With Emoji');
|
|
605
|
+
const idxNo = noEmojiLine.indexOf('No Emoji');
|
|
606
|
+
expect(idxWith).toBe(idxNo);
|
|
607
|
+
expect(result).not.toContain('undefined');
|
|
608
|
+
expect(result).not.toContain('null');
|
|
609
|
+
});
|
|
610
|
+
it('shows truncation hint when total > returned items', () => {
|
|
611
|
+
const jobTypes = Array.from({ length: 100 }, (_, i) => ({
|
|
612
|
+
id: `jt-${i}`,
|
|
613
|
+
name: `JT ${i}`
|
|
614
|
+
}));
|
|
615
|
+
const result = formatContext({ localConfig, total: 247, jobTypes });
|
|
616
|
+
expect(result).toContain('Job types available (247):');
|
|
617
|
+
expect(result).toContain('… and 147 more job types not shown');
|
|
618
|
+
});
|
|
619
|
+
it('produces byte-equal output across calls with identical input', () => {
|
|
620
|
+
const input = {
|
|
621
|
+
localConfig,
|
|
622
|
+
total: 1,
|
|
623
|
+
jobTypes: [{ id: 'a', name: 'A', emoji: '🅰️' }]
|
|
624
|
+
};
|
|
625
|
+
const a = formatContext(input);
|
|
626
|
+
const b = formatContext(input);
|
|
627
|
+
expect(a).toBe(b);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Factory that returns a fresh Zod schema validating ISO 8601 date-time strings.
|
|
4
|
+
*
|
|
5
|
+
* Accepts any string parseable by `new Date(value)` (full ISO 8601 with `Z` or
|
|
6
|
+
* timezone offset, and date-only forms like `2024-07-23`).
|
|
7
|
+
*
|
|
8
|
+
* IMPORTANT — why this is a factory and not a singleton: when the MCP SDK
|
|
9
|
+
* serializes a tool's input shape to JSON Schema (via `zod-to-json-schema`),
|
|
10
|
+
* reusing the same Zod *instance* across multiple fields causes the serializer
|
|
11
|
+
* to emit `$ref` from sibling fields back to the first one. Many clients/LLMs
|
|
12
|
+
* then treat those fields as `any` and send `null`, which the server rejects.
|
|
13
|
+
* Returning a new instance per call guarantees inline definitions per field.
|
|
14
|
+
*
|
|
15
|
+
* Convention for this project: every reusable Zod schema referenced by more
|
|
16
|
+
* than one tool field MUST be exported as a factory (`() => z.something(...)`),
|
|
17
|
+
* never as a singleton `const`.
|
|
18
|
+
*/
|
|
19
|
+
export const flexibleDateTimeSchema = () => z.string().refine((value) => {
|
|
20
|
+
const date = new Date(value);
|
|
21
|
+
return !isNaN(date.getTime());
|
|
22
|
+
}, {
|
|
23
|
+
message: "Invalid date-time string. Please use a valid ISO 8601 format.",
|
|
24
|
+
});
|