@indemn/cli 1.7.1 → 1.7.3

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 (40) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/mcp/registry.d.ts.map +1 -1
  4. package/dist/mcp/registry.js +6 -4
  5. package/dist/mcp/registry.js.map +1 -1
  6. package/dist/mcp/tool-meta.d.ts.map +1 -1
  7. package/dist/mcp/tool-meta.js +35 -9
  8. package/dist/mcp/tool-meta.js.map +1 -1
  9. package/dist/mcp/tools.d.ts.map +1 -1
  10. package/dist/mcp/tools.js +235 -49
  11. package/dist/mcp/tools.js.map +1 -1
  12. package/dist/report/styles.d.ts +1 -0
  13. package/dist/report/styles.d.ts.map +1 -1
  14. package/dist/report/styles.js +5 -1
  15. package/dist/report/styles.js.map +1 -1
  16. package/dist/sdk/client.d.ts.map +1 -1
  17. package/dist/sdk/client.js +11 -1
  18. package/dist/sdk/client.js.map +1 -1
  19. package/dist/sdk/config-change.d.ts +4 -0
  20. package/dist/sdk/config-change.d.ts.map +1 -1
  21. package/dist/sdk/config-change.js +27 -5
  22. package/dist/sdk/config-change.js.map +1 -1
  23. package/dist/sdk/kb.d.ts +13 -0
  24. package/dist/sdk/kb.d.ts.map +1 -1
  25. package/dist/sdk/kb.js +48 -6
  26. package/dist/sdk/kb.js.map +1 -1
  27. package/dist/sdk/voice.js +5 -5
  28. package/dist/sdk/voice.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/mcp/registry.ts +9 -4
  31. package/src/mcp/tool-meta.ts +35 -9
  32. package/src/mcp/tools.ts +238 -49
  33. package/src/report/styles.ts +5 -1
  34. package/src/sdk/client.ts +5 -1
  35. package/src/sdk/config-change.ts +27 -5
  36. package/src/sdk/kb.ts +53 -10
  37. package/src/sdk/voice.ts +5 -5
  38. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
  39. package/test/mcp/darwin-fixes.test.ts +249 -0
  40. package/test/mcp/tools-snapshot.test.ts +1 -1
@@ -10,12 +10,16 @@ export interface ConfigChange {
10
10
  system_prompt?: string;
11
11
  greeting?: string;
12
12
  model?: { provider: string; model: string };
13
+ /** Model settings the dashboard exposes (temperature, …), merged into the saved ones. */
14
+ parameters?: Record<string, unknown>;
13
15
  }
14
16
 
15
17
  interface Snapshot {
16
18
  system_prompt: string;
17
19
  greeting: string;
18
20
  model: string;
21
+ /** The model's settings as JSON, so a settings-only change is a change. */
22
+ parameters: string;
19
23
  }
20
24
 
21
25
  export interface ConfigPlan {
@@ -33,11 +37,12 @@ export interface ConfigPlan {
33
37
  }
34
38
 
35
39
  function snapshot(cfg: BotConfiguration): Snapshot {
36
- const llm = (cfg as { llm_config?: { provider?: string; model?: string } }).llm_config;
40
+ const llm = (cfg as { llm_config?: { provider?: string; model?: string; parameters?: Record<string, unknown> } }).llm_config;
37
41
  return {
38
42
  system_prompt: cfg.ai_config?.system_prompt ?? '',
39
43
  greeting: cfg.first_message?.text ?? '',
40
44
  model: llm?.model ? `${llm.provider ?? '?'}/${llm.model}` : '',
45
+ parameters: JSON.stringify(llm?.parameters ?? {}),
41
46
  };
42
47
  }
43
48
 
@@ -69,17 +74,27 @@ function channelTypes(agent: { channels?: unknown }): string[] {
69
74
  export async function planConfigChange(client: IndemnClient, agentId: string, change: ConfigChange): Promise<ConfigPlan> {
70
75
  const raw = await new ConfigSDK(client).getConfig(agentId);
71
76
  const current = snapshot(raw);
77
+ const savedLlm = (raw as { llm_config?: { provider?: string; model?: string; parameters?: Record<string, unknown> } }).llm_config;
78
+ const saved = savedLlm?.parameters ?? {};
79
+ const wanted = change.parameters ? { ...saved, ...change.parameters } : saved;
72
80
  const next: Snapshot = {
73
81
  system_prompt: change.system_prompt ?? current.system_prompt,
74
82
  greeting: change.greeting ?? current.greeting,
75
83
  model: change.model ? `${change.model.provider}/${change.model.model}` : current.model,
84
+ parameters: JSON.stringify(wanted),
76
85
  };
77
86
  const changes = (Object.keys(next) as Array<keyof Snapshot>).filter((k) => next[k] !== current[k]);
78
87
  let llm: ConfigPlan['llm'];
79
- if (change.model && changes.includes('model')) {
88
+ if ((change.model && changes.includes('model')) || (change.parameters && changes.includes('parameters'))) {
89
+ const model = change.model ?? (savedLlm?.provider && savedLlm.model ? { provider: savedLlm.provider, model: savedLlm.model } : undefined);
90
+ if (!model) throw new CliError('The agent has no model yet; set one before changing its settings.');
80
91
  const agent = await new AgentsSDK(client).getAgent(agentId);
81
- const saved = ((raw as { llm_config?: { parameters?: Record<string, unknown> } }).llm_config?.parameters) ?? {};
82
- llm = await modelForAgent(client, change.model, channelTypes(agent), saved);
92
+ // The catalog decides which settings the model takes; a wanted value the model does not expose is refused.
93
+ llm = await modelForAgent(client, model, channelTypes(agent), wanted);
94
+ const unknown = Object.keys(change.parameters ?? {}).filter((k) => !(k in llm!.parameters));
95
+ if (unknown.length) throw new CliError(`${model.provider}/${model.model} has no setting named ${unknown.join(', ')}.`);
96
+ next.parameters = JSON.stringify(llm.parameters);
97
+ if (next.parameters === current.parameters) changes.splice(changes.indexOf('parameters'), changes.includes('parameters') ? 1 : 0);
83
98
  }
84
99
  let lifecycle = 'unknown';
85
100
  try {
@@ -110,6 +125,12 @@ export function describePlan(plan: ConfigPlan, opts: { maxDiffLines?: number } =
110
125
  }
111
126
  if (plan.changes.includes('greeting')) parts.push(`Greeting: "${plan.current.greeting}" -> "${plan.next.greeting}".`);
112
127
  if (plan.changes.includes('model')) parts.push(`Model: ${plan.current.model || '(none)'} -> ${plan.next.model}.`);
128
+ if (plan.changes.includes('parameters')) {
129
+ const before = JSON.parse(plan.current.parameters) as Record<string, unknown>;
130
+ const after = JSON.parse(plan.next.parameters) as Record<string, unknown>;
131
+ const changed = Object.keys(after).filter((k) => JSON.stringify(after[k]) !== JSON.stringify(before[k]));
132
+ parts.push(`Model settings: ${changed.map((k) => `${k} ${before[k] === undefined ? '(unset)' : JSON.stringify(before[k])} -> ${JSON.stringify(after[k])}`).join(', ')}.`);
133
+ }
113
134
  return parts.join('\n');
114
135
  }
115
136
 
@@ -130,7 +151,8 @@ export async function applyConfigChange(client: IndemnClient, plan: ConfigPlan,
130
151
  if (plan.changes.includes('greeting')) {
131
152
  input.first_message = JSON.stringify({ ...(plan.raw.first_message ?? {}), text: change.greeting });
132
153
  }
133
- if (plan.changes.includes('model') && change.model) input.llm_config = plan.llm ?? { provider: change.model.provider, model: change.model.model };
154
+ if ((plan.changes.includes('model') || plan.changes.includes('parameters')) && plan.llm) input.llm_config = plan.llm;
155
+ else if (plan.changes.includes('model') && change.model) input.llm_config = { provider: change.model.provider, model: change.model.model };
134
156
  if (Object.keys(input).length === 0) return { applied: [], mismatched: [] };
135
157
  const sdk = new ConfigSDK(client);
136
158
  await sdk.setConfig(plan.agentId, input);
package/src/sdk/kb.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { IndemnClient } from './client.js';
2
+ import { CliError, EXIT } from '../core/runtime.js';
2
3
  import type {
3
4
  KnowledgeBase,
4
5
  CreateKBInput,
@@ -121,14 +122,8 @@ export class KBSDK {
121
122
  throw new Error(`${fileName} is ${(size / 1024 / 1024).toFixed(1)} MB; the limit is 10 MB.`);
122
123
  }
123
124
 
124
- const formData = new FormData();
125
- formData.append('file', new Blob([fs.readFileSync(filePath)]), fileName);
126
- const stored = await this.client.copilotPostForm<{ url?: string; signedUrl?: string; data?: { url?: string; signedUrl?: string } }>(
127
- '/organization/:org_id/files/upload-org?generateSignedUrl=true',
128
- formData,
129
- );
130
125
  // The import service must fetch the file, so it needs the signed link.
131
- const url = stored?.signedUrl ?? stored?.data?.signedUrl ?? stored?.url ?? stored?.data?.url;
126
+ const { signedUrl: url } = await this.storeFile(filePath);
132
127
  if (!url) throw new Error(`Uploading ${fileName} returned no file link; nothing was imported.`);
133
128
 
134
129
  return this.importKB(kbId, {
@@ -138,17 +133,63 @@ export class KBSDK {
138
133
  });
139
134
  }
140
135
 
136
+ /** Put a local file into the organization's file store; its stored link and a signed link to download it. */
137
+ async storeFile(filePath: string): Promise<{ url?: string; signedUrl?: string }> {
138
+ const fs = await import('fs');
139
+ const path = await import('path');
140
+ const formData = new FormData();
141
+ formData.append('file', new Blob([fs.readFileSync(filePath)]), path.basename(filePath));
142
+ const stored = await this.client.copilotPostForm<{ url?: string; signedUrl?: string; data?: { url?: string; signedUrl?: string } }>(
143
+ '/organization/:org_id/files/upload-org?generateSignedUrl=true',
144
+ formData,
145
+ );
146
+ return { url: stored?.url ?? stored?.data?.url, signedUrl: stored?.signedUrl ?? stored?.data?.signedUrl ?? stored?.url ?? stored?.data?.url };
147
+ }
148
+
149
+ /**
150
+ * A signed download link for a file in the organization's file store, given its stored (unsigned) link.
151
+ * Exports return unsigned links that the browser refuses (403); the file store signs them on request.
152
+ */
153
+ async signedLink(storedUrl: string): Promise<string | undefined> {
154
+ const key = storedUrl.replace(/^https?:\/\/[^/]+\//, '').split('?')[0];
155
+ if (!key) return undefined;
156
+ const res = await this.client.copilotGet<{ signedUrl?: string; url?: string; download_url?: string; data?: { signedUrl?: string; url?: string } }>(
157
+ '/organization/:org_id/files/signed-url', { filename: key },
158
+ ).catch(() => undefined);
159
+ return res?.signedUrl ?? res?.download_url ?? res?.data?.signedUrl ?? res?.url ?? res?.data?.url;
160
+ }
161
+
141
162
  async updateDataSource(
142
163
  kbId: string,
143
164
  sourceId: string,
144
165
  input: Partial<AddDataSourceInput>,
145
166
  ): Promise<DataSource> {
167
+ // copilot needs both fields on every update (and answers 500, not 400, when one is missing): fill the
168
+ // one the caller left out from the saved entry.
169
+ let { question, answer } = input;
170
+ if (question === undefined || answer === undefined) {
171
+ const saved = await this.findDataSource(kbId, sourceId);
172
+ question ??= saved?.question;
173
+ answer ??= saved?.answer;
174
+ }
175
+ if (!question || !answer) throw new CliError('A question/answer entry needs both its question and its answer.', EXIT.NEEDS_INPUT);
146
176
  return this.client.copilotPut<DataSource>(
147
177
  `/organization/:org_id/knowledge-bases/${kbId}/data-source/${sourceId}`,
148
- { payload: { question: input.question, answer: input.answer, isManual: true } },
178
+ { payload: { question, answer, isManual: true } },
149
179
  );
150
180
  }
151
181
 
182
+ /** One entry of a knowledge base by id, or undefined (the API lists entries by page only). */
183
+ async findDataSource(kbId: string, sourceId: string): Promise<DataSource | undefined> {
184
+ for (let page = 1; page <= 50; page++) {
185
+ const res = await this.listDataSources(kbId, { page: String(page), limit: '100' });
186
+ const hit = (res.dataSources ?? []).find((d) => d._id === sourceId);
187
+ if (hit) return hit;
188
+ if (!res.pages || page >= res.pages || !(res.dataSources ?? []).length) return undefined;
189
+ }
190
+ return undefined;
191
+ }
192
+
152
193
  async deleteDataSource(kbId: string, sourceId: string): Promise<void> {
153
194
  await this.client.copilotDelete(
154
195
  `/organization/:org_id/knowledge-bases/${kbId}/data-source/${sourceId}`,
@@ -223,11 +264,13 @@ export class KBSDK {
223
264
  * Crawl and file-import status. copilot has no status route, so this reads
224
265
  * the KB's non-manual sources and their scrape jobs.
225
266
  */
226
- async scrapeStatus(kbId: string): Promise<Array<{ id?: string; source?: string; status?: string; job_status?: string; error?: string; completed_at?: string }>> {
267
+ async scrapeStatus(kbId: string): Promise<Array<{ id?: string; name?: string; source?: string; status?: string; job_status?: string; error?: string; completed_at?: string }>> {
227
268
  const kb = await this.getKB(kbId) as unknown as { fileWebSources?: Array<Record<string, any>> };
228
269
  return (kb.fileWebSources ?? []).map((s) => ({
229
270
  id: s._id ? String(s._id) : undefined,
230
- source: s.source,
271
+ // An uploaded file's source is its signed storage link: name it by its file name (as the dashboard does)
272
+ // and keep the link without its signature.
273
+ ...(s.type === 'file' ? { name: s.source_details?.file_name ?? undefined, source: s.source ? String(s.source).split('?')[0] : undefined } : { source: s.source }),
231
274
  status: s.status,
232
275
  job_status: s.source_details?.id_scrape_request?.status,
233
276
  error: s.error_message ?? s.source_details?.id_scrape_request?.message,
package/src/sdk/voice.ts CHANGED
@@ -116,7 +116,7 @@ export class VoiceSDK {
116
116
  /** The agent's voice settings. Voice settings live on its phone number, so an agent without one has none. */
117
117
  async getAgentVoice(agentId: string): Promise<Record<string, unknown>> {
118
118
  const numbers = await this.agentNumbers(agentId);
119
- if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it has no voice settings yet. Attach one with \`indemn agents numbers attach\`.`, EXIT.NEEDS_INPUT);
119
+ if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it has no voice settings yet. Attach a phone number to it first.`, EXIT.NEEDS_INPUT);
120
120
  return (numbers[0].modelSettings ?? {}) as Record<string, unknown>;
121
121
  }
122
122
 
@@ -191,7 +191,7 @@ export class VoiceSDK {
191
191
  /** The agent's phone number with its location and a summary of its voice settings. */
192
192
  async numberDetails(agentId: string): Promise<Record<string, unknown>> {
193
193
  const [num] = await this.agentNumbers(agentId);
194
- if (!num) throw new CliError(`Agent ${agentId} has no phone number. Find one with \`indemn agents numbers search\`.`, EXIT.NEEDS_INPUT);
194
+ if (!num) throw new CliError(`Agent ${agentId} has no phone number. Search for one and attach it first.`, EXIT.NEEDS_INPUT);
195
195
  const d = (num.additionalDetails ?? {}) as AvailableNumber;
196
196
  const m = (num.modelSettings ?? {}) as { tts?: { provider?: string; name?: string; settings?: { speed?: number } }; stt?: { provider?: string; model?: string; language?: string } };
197
197
  return {
@@ -218,13 +218,13 @@ export class VoiceSDK {
218
218
  const existing = await this.agentNumbers(agentId);
219
219
  if (existing.length) throw new CliError(`"${agent.name}" already has ${existing[0].phoneNumber}. Nothing was bought.`);
220
220
  if ((await this.orgNumbers()).some((o) => o.phoneNumber === phoneNumber)) {
221
- throw new CliError(`The org already owns ${phoneNumber}; attach it with \`indemn agents numbers attach\` instead of buying it. Nothing was bought.`);
221
+ throw new CliError(`The org already owns ${phoneNumber}; attach it instead of buying it. Nothing was bought.`);
222
222
  }
223
223
  const digits = phoneNumber.replace(/\D/g, '');
224
224
  const offered = await this.searchNumbers({ country, tollFree: opts.tollFree, contains: digits.slice(-7), limit: 20,
225
225
  ...((country === 'US' || country === 'CA') && !opts.tollFree ? { areaCode: digits.slice(1, 4) } : {}) });
226
226
  const pick = offered.find((o) => o.phoneNumber === phoneNumber);
227
- if (!pick) throw new CliError(`${phoneNumber} is no longer available. Search again with \`indemn agents numbers search\`. Nothing was bought.`);
227
+ if (!pick) throw new CliError(`${phoneNumber} is no longer available. Search again. Nothing was bought.`);
228
228
  await this.client.copilotPost(`/organization/:org_id/ai-studio/bots/${agentId}/configurations/voice/numbers`, {
229
229
  type: opts.tollFree ? 'toll-free' : 'local', friendlyName: pick.friendlyName ?? phoneNumber, phoneNumber, payload: pick,
230
230
  });
@@ -249,7 +249,7 @@ export class VoiceSDK {
249
249
  async voicePreview(agentId: string): Promise<{ server_url: string; room_name: string; token: string; join_url: string }> {
250
250
  // The call service finds the agent by its phone number: without one the agent never joins the room.
251
251
  const numbers = await this.agentNumbers(agentId);
252
- if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it cannot take a test call. Attach one with \`indemn agents numbers attach\`.`, EXIT.NEEDS_INPUT);
252
+ if (!numbers.length) throw new CliError(`Agent ${agentId} has no phone number, so it cannot take a test call. Attach a phone number to it first.`, EXIT.NEEDS_INPUT);
253
253
  const res = await this.client.copilotPost<{ token: string; server_url: string; room_name: string }>('/livekit/token', {
254
254
  bot_id: agentId,
255
255
  user_attributes: { 'sip.twilio.callSid': callSid(), isTestMode: 'true', ...(numbers[0].phoneNumber ? { 'sip.trunkPhoneNumber': numbers[0].phoneNumber } : {}) },
@@ -111,6 +111,7 @@ exports[`MCP tool list > gives every tool a title and annotations 1`] = `
111
111
  "platform_save",
112
112
  "promote_prod_call",
113
113
  "reimport_kb_source",
114
+ "remove_holiday",
114
115
  "run_evaluation",
115
116
  "run_simulation",
116
117
  "save_ticket_rule",
@@ -0,0 +1,249 @@
1
+ // Fixes from the Darwin end-to-end run of 2026-10-01: each test fails on the code before the fix.
2
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
3
+ import { on, recordFetch, type RecordedCall } from '../helpers/fetch-recorder.js';
4
+ import { cleanEnv } from '../helpers/config-home.js';
5
+ import { connectPair } from '../helpers/mcp-pair.js';
6
+ import { IndemnClient } from '../../src/sdk/client.js';
7
+ import { registerTools } from '../../src/mcp/tools.js';
8
+ import { assetsDir } from '../../src/report/styles.js';
9
+ import { existsSync } from 'fs';
10
+ import { join } from 'path';
11
+
12
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
13
+ const AGENT = 'bbbbbbbbbbbbbbbbbbbbbbbb';
14
+ const approve = () => ({ action: 'accept' as const, content: { confirm: true } });
15
+ const ok = (data: unknown) => ({ json: { success: true, data } });
16
+
17
+ async function pair(elicit = approve) {
18
+ cleanEnv();
19
+ const client = new IndemnClient({ apiKey: `ind_dev_${'a'.repeat(64)}`, orgId: ORG, environment: 'dev' });
20
+ return connectPair((server) => { registerTools(server, client); }, { elicit });
21
+ }
22
+ const bodyOf = (calls: RecordedCall[], method: string, part: string) => calls.find((c) => c.method === method && c.url.includes(part))?.body as any;
23
+
24
+ describe('Darwin E2E fixes', () => {
25
+ let p: Awaited<ReturnType<typeof pair>> | undefined;
26
+ beforeEach(() => { p = undefined; });
27
+ afterEach(async () => { await p?.close(); });
28
+
29
+ it('import_kb takes the import as JSON text and refuses a shape the import service would ignore', async () => {
30
+ const { calls } = recordFetch([
31
+ on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Roadside' })),
32
+ on('POST', '/knowledge-bases/kb1/import', ok({ status: 'started', message: 'file import has been initiated' })),
33
+ ]);
34
+ p = await pair();
35
+ const text = JSON.stringify({ type: 'file', sources: [{ url: 'https://store.test/org/1790869827796.txt', name: 'Battery jump starts.txt' }] });
36
+ const res = await p.call('import_kb', { kb_id: 'kb1', data: text });
37
+ expect(res.isError).toBe(false);
38
+ expect(bodyOf(calls, 'POST', '/import')).toEqual({ type: 'file', sources: [{ url: 'https://store.test/org/1790869827796.txt', name: 'Battery jump starts.txt', file_type: 'txt' }] });
39
+ expect(res.body.data.imported).toEqual(['Battery jump starts.txt']);
40
+ expect(p.questions[0].message).toContain('"Battery jump starts.txt"'); // the card names the file
41
+
42
+ const wrong = await p.call('import_kb', { kb_id: 'kb1', data: { files: [{ url: 'https://store.test/x.txt' }] } });
43
+ expect(wrong.isError).toBe(true);
44
+ expect(calls.filter((c) => c.method === 'POST' && c.url.includes('/import'))).toHaveLength(1); // nothing sent for the wrong shape
45
+ });
46
+
47
+ it('update_ticket adds a note to the notes instead of dropping it', async () => {
48
+ const { calls } = recordFetch([
49
+ on('GET', '/tickets/t1', ok({ _id: 't1', ticket_ref: 'TKT-1', notes: 'Old note' })),
50
+ on('PUT', '/tickets/t1', ok({ _id: 't1', notes: 'Old note\nChecked with towing partner.', status: 'in_progress' })),
51
+ ]);
52
+ p = await pair();
53
+ const res = await p.call('update_ticket', { ticket_id: 't1', status: 'in_progress', note: 'Checked with towing partner.' });
54
+ expect(res.isError).toBe(false);
55
+ expect(bodyOf(calls, 'PUT', '/tickets/t1')).toEqual({ status: 'in_progress', notes: 'Old note\nChecked with towing partner.' });
56
+ });
57
+
58
+ it('eval_gate_verdict judges against 80% when no threshold is given', async () => {
59
+ recordFetch([on('GET', '/api/v1/runs/r1', { json: { run_id: 'r1', status: 'completed', total: 3, passed: 1 } })]);
60
+ p = await pair();
61
+ const res = await p.call('eval_gate_verdict', { run_id: 'r1' });
62
+ expect(res.body.data).toMatchObject({ finished: true, pass: false, min_pass_rate: 0.8 });
63
+ expect(res.body.data.reasons.join(' ')).toContain('< 0.8');
64
+ });
65
+
66
+ it('add_holiday fills the type and the closed message the server requires', async () => {
67
+ const { calls } = recordFetch([on('PATCH', '/configurations/holidays', ok({ saved: true }))]);
68
+ p = await pair();
69
+ const res = await p.call('add_holiday', { date: '2026-12-31', name: "New Year's Eve" });
70
+ expect(res.isError).toBe(false);
71
+ expect(bodyOf(calls, 'PATCH', '/holidays').holidays[0]).toEqual({
72
+ name: "New Year's Eve", date: '2026-12-31', type: 'custom', holidayFallbackUtterance: "Our offices are currently closed on account of New Year's Eve.",
73
+ });
74
+ const gone = await p.call('remove_holiday', { date: '2026-12-31' });
75
+ expect(gone.isError).toBe(false);
76
+ expect(bodyOf(calls, 'DELETE', '/holidays')).toEqual({ holidays: [{ date: '2026-12-31' }] });
77
+ });
78
+
79
+ it('list_agent_numbers (org-wide) includes the numbers attached to agents', async () => {
80
+ recordFetch([
81
+ on('GET', '/ai-studio/v2/bots', ok([{ _id: AGENT, name: 'Rudra-voice-3', channels: ['VOICE'], connected_phone: '+12562897334' }, { _id: 'c', name: 'Web', channels: ['WEB'] }])),
82
+ on('GET', '/voice/configurations/numbers', ok([{ phoneNumber: '+18664857140', friendlyName: '(866) 485-7140', type: 'tollfree' }])),
83
+ ]);
84
+ p = await pair();
85
+ const res = await p.call('list_agent_numbers');
86
+ expect(res.body.data).toEqual([
87
+ { phone_number: '+12562897334', attached: true, agent_id: AGENT, agent_name: 'Rudra-voice-3' },
88
+ { phone_number: '+18664857140', attached: false, name: '(866) 485-7140', type: 'tollfree' },
89
+ ]);
90
+ });
91
+
92
+ it('create_function saves the parameters it is given', async () => {
93
+ const { calls } = recordFetch([
94
+ on('GET', `/bots/${AGENT}/functions/f1/parameters`, ok([])),
95
+ on('GET', `/bots/${AGENT}/functions`, ok([])),
96
+ on('POST', `/bots/${AGENT}/functions/f1/parameters`, ok([{ _id: 'p1', name: 'zip_code', type: 'string', is_required: true }])),
97
+ on('PUT', `/bots/${AGENT}/functions/f1`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
98
+ on('POST', `/bots/${AGENT}/functions`, ok({ _id: 'f1', name: 'Weather', type: 'rest_api' })),
99
+ ]);
100
+ p = await pair();
101
+ const res = await p.call('create_function', {
102
+ agent_id: AGENT, type: 'rest_api', name: 'Weather', url: 'https://httpbin.org/get?zip={{zip_code}}', method: 'GET',
103
+ parameters: [{ name: 'zip_code', type: 'string', description: 'ZIP code', required: true }],
104
+ });
105
+ expect(res.isError).toBe(false);
106
+ expect(bodyOf(calls, 'POST', '/functions/f1/parameters')).toEqual([{ name: 'zip_code', type: 'string', description: 'ZIP code', is_required: true }]);
107
+ expect(res.body.data.parameters).toHaveLength(1);
108
+ });
109
+
110
+ it('keeps the server\'s reason on a 403 instead of "Access denied"', async () => {
111
+ recordFetch([on('GET', '/tickets', { status: 403, json: { success: false, error: 'Tickets are not enabled for this organization.' } })]);
112
+ p = await pair();
113
+ const res = await p.call('list_tickets');
114
+ expect(res.isError).toBe(true);
115
+ expect(res.body).toContain('Tickets are not enabled for this organization.');
116
+ });
117
+
118
+ it('set_business_hours replaces every day and rebuilds the hours message', async () => {
119
+ const { calls } = recordFetch([
120
+ on('GET', '/configurations', ok({ businessHoursConfig: { businessHoursEnabled: false } })),
121
+ on('PATCH', '/configurations/business-hours', ok({ success: true })),
122
+ ]);
123
+ p = await pair();
124
+ const hours = [1, 2, 3, 4, 5].map((day) => ({ day, slots: [{ start: '08:00', end: '18:00' }] }));
125
+ const res = await p.call('set_business_hours', { hours: JSON.stringify(hours) });
126
+ expect(res.isError).toBe(false);
127
+ const body = bodyOf(calls, 'PATCH', '/business-hours');
128
+ expect(body.businessHoursEnabled).toBe(false); // unchanged: not asked to enforce them
129
+ expect(body.businessHours).toHaveLength(7);
130
+ expect(body.businessHours.find((d: { day: number }) => d.day === 6).slots).toEqual([]); // Saturday closed now
131
+ expect(body.businessHoursUtterance).toBe("We're open Monday 8am to 6pm; Tuesday 8am to 6pm; Wednesday 8am to 6pm; Thursday 8am to 6pm; Friday 8am to 6pm.");
132
+ expect(p.questions[0].message).toContain('Monday 8am to 6pm');
133
+ });
134
+
135
+ it('evaluate_transcript accepts "agent" turns, needs an evaluator and a different replay', async () => {
136
+ const { calls } = recordFetch([
137
+ on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Voice Test Agent' }])),
138
+ on('GET', '/evaluator-configs', { json: { items: [{ config_id: 'c1' }] } }),
139
+ on('POST', '/api/v1/evaluate-transcript', { json: { success: true, score: 0.9 } }),
140
+ ]);
141
+ p = await pair();
142
+ const baseline = [{ sender: 'user', text: 'Hi' }, { sender: 'agent', text: 'Hello' }, { sender: 'system', text: 'ended' }];
143
+ const same = await p.call('evaluate_transcript', { agent_id: AGENT, baseline_transcript: baseline, replay_transcript: baseline });
144
+ expect(same.isError).toBe(true);
145
+ expect(String(same.body)).toContain('same conversation');
146
+ expect(p.questions).toHaveLength(0); // no card for something that cannot run
147
+
148
+ const res = await p.call('evaluate_transcript', { agent_id: AGENT, baseline_transcript: baseline, replay_transcript: [{ sender: 'user', text: 'Hi' }, { sender: 'agent', text: 'Hey there' }] });
149
+ expect(res.isError).toBe(false);
150
+ expect(bodyOf(calls, 'POST', '/evaluate-transcript').baseline_transcript).toEqual([{ sender: 'user', text: 'Hi' }, { sender: 'bot', text: 'Hello' }]);
151
+ });
152
+
153
+ it('list_kb_data leaves the chunks out unless asked', async () => {
154
+ recordFetch([on('GET', '/knowledge-bases/kb1/data-source', ok({
155
+ dataSources: [{ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true, chunks: [{ text: 'x' }], chunks_text: ['x'] }], total: 1, page: 1, pages: 1,
156
+ }))]);
157
+ p = await pair();
158
+ const slim = await p.call('list_kb_data', { kb_id: 'kb1' });
159
+ expect(slim.body.data.dataSources[0]).toEqual({ _id: 's1', type: 'text', question: 'Do you tow motorcycles?', answer: 'Yes', trained: true });
160
+ const full = await p.call('list_kb_data', { kb_id: 'kb1', full: true });
161
+ expect(full.body.data.dataSources[0].chunks).toHaveLength(1);
162
+ // Results are compact JSON (no pretty-printing) to save the model tokens.
163
+ expect((slim.raw.content as Array<{ text: string }>)[0].text.startsWith('{"context"')).toBe(true);
164
+ });
165
+
166
+ it('export_kb hands out a signed download link', async () => {
167
+ recordFetch([
168
+ on('POST', '/knowledge-bases/kb1/export', ok({ fileUrl: 'https://indemn-dev-attachments.s3.us-east-1.amazonaws.com/org/1790869575391.csv', fileType: 'csv' })),
169
+ on('GET', '/files/signed-url', (c) => (c.url.includes('filename=org%2F1790869575391.csv') || c.url.includes('filename=org/1790869575391.csv')
170
+ ? { json: { success: true, data: { signedUrl: 'https://indemn-dev-attachments.s3.us-east-1.amazonaws.com/org/1790869575391.csv?X-Amz-Signature=abc' } } }
171
+ : { status: 404, json: { success: false } })),
172
+ ]);
173
+ p = await pair();
174
+ const res = await p.call('export_kb', { kb_id: 'kb1' });
175
+ expect(res.body.data.download_url).toContain('X-Amz-Signature=abc');
176
+ });
177
+
178
+ it('finds the report fonts (the published package carries them under src/)', () => {
179
+ expect(existsSync(join(assetsDir, 'fonts', 'Barlow-Regular.ttf'))).toBe(true);
180
+ });
181
+ it('clone_agent carries the description over to the copy, which copilot answers as new_agent_id', async () => {
182
+ const COPY = 'cccccccccccccccccccccccc';
183
+ const { calls } = recordFetch([
184
+ on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent', description: 'Roadside help, short answers.' }])),
185
+ on('POST', '/ai-studio/v2/bots/clone', ok({ new_agent_id: COPY, new_agent_name: 'Darwin QA Clone', new_kb_ids: [] })),
186
+ on('PUT', `/ai-studio/bots/${COPY}`, ok({ _id: COPY, name: 'Darwin QA Clone', description: 'Roadside help, short answers.' })),
187
+ ]);
188
+ p = await pair();
189
+ const res = await p.call('clone_agent', { agent_id: AGENT, name: 'Darwin QA Clone' });
190
+ expect(res.isError).toBe(false);
191
+ expect(bodyOf(calls, 'PUT', `/ai-studio/bots/${COPY}`)).toEqual({ description: 'Roadside help, short answers.' });
192
+ expect(res.body.data).toMatchObject({ new_agent_id: COPY, description: 'Roadside help, short answers.' });
193
+ });
194
+ it('update_kb_data asks first, naming the entry and the knowledge base', async () => {
195
+ const { calls } = recordFetch([
196
+ on('GET', '/knowledge-bases/kb1/data-source', ok({ dataSources: [{ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 14 days before.' }], total: 1, page: 1, pages: 1 })),
197
+ on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Darwin KB Test' })),
198
+ on('PUT', '/knowledge-bases/kb1/data-source/s1', ok({ _id: 's1', question: 'How late can I buy a policy?', answer: 'Up to 7 days before.' })),
199
+ ]);
200
+ p = await pair();
201
+ const res = await p.call('update_kb_data', { kb_id: 'kb1', source_id: 's1', answer: 'Up to 7 days before.' });
202
+ expect(res.isError).toBe(false);
203
+ expect(p.questions).toHaveLength(1);
204
+ expect(p.questions[0].message).toContain('"How late can I buy a policy?"');
205
+ expect(p.questions[0].message).toContain('Up to 7 days before.');
206
+ expect(bodyOf(calls, 'PUT', '/data-source/s1')).toEqual({ payload: { question: 'How late can I buy a policy?', answer: 'Up to 7 days before.', isManual: true } });
207
+ });
208
+ it('list_audit_logs names the person behind each entry from the member list', async () => {
209
+ recordFetch([
210
+ on('GET', '/audit-logs', ok([{ _id: 'a1', id_user: 'u1', action: 'update', resource_type: 'bot', resource_display: 'Darwin QA Agent', createdAt: '2026-10-01T10:00:00Z' }, { _id: 'a2', id_user: 'u9', action: 'create', createdAt: '2026-10-01T09:00:00Z' }])),
211
+ on('GET', '/projects-users', ok({ projectUsers: [{ role: 'owner', status: 'active', id_user: { _id: 'u1', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
212
+ ]);
213
+ p = await pair();
214
+ const res = await p.call('list_audit_logs', {});
215
+ expect(res.isError).toBe(false);
216
+ expect(res.body.data[0]).toMatchObject({ id_user: 'u1', user_name: 'Ana Diaz', user_email: 'ana@acme.com' });
217
+ expect(res.body.data[1].user_name).toBeUndefined(); // not a member any more: the id stays as it is
218
+ });
219
+
220
+ it('transfer_agent_owner names the new owner on its card', async () => {
221
+ const { calls } = recordFetch([
222
+ on('GET', `/ai-studio/v2/bots/${AGENT}`, ok([{ _id: AGENT, name: 'Darwin QA Agent' }])),
223
+ on('GET', '/projects-users', ok({ projectUsers: [{ role: 'admin', status: 'active', id_user: { _id: 'cccccccccccccccccccccccc', email: 'ana@acme.com', firstname: 'Ana', lastname: 'Diaz' }, id_project: { name: 'default' } }] })),
224
+ ]);
225
+ p = await pair(() => ({ action: 'decline' as const }));
226
+ await p.call('transfer_agent_owner', { agent_id: AGENT, owner_id: 'cccccccccccccccccccccccc' });
227
+ expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0); // declined: nothing transferred
228
+ expect(p.questions[0].message).toContain('"Ana Diaz"');
229
+ expect(p.questions[0].message).toContain('"Darwin QA Agent"');
230
+ });
231
+ it('delete_kb_data names an uploaded file on its card, and the knowledge base status hides the signed link', async () => {
232
+ const signed = 'https://store.test/org/1790918471122.txt?X-Amz-Signature=deadbeef';
233
+ recordFetch([
234
+ on('GET', '/knowledge-bases/kb1/data-source', ok({ dataSources: [], total: 0, page: 1, pages: 1 })),
235
+ on('GET', '/knowledge-bases/kb1', ok({ _id: 'kb1', name: 'Darwin KB Test', fileWebSources: [
236
+ { _id: 'f1', type: 'file', source: signed, status: 'success', source_details: { file_name: 'Battery jump starts.txt' } },
237
+ { _id: 'u1', type: 'url', source: 'https://www.indemn.ai/?ref=1', status: 'success' },
238
+ ] })),
239
+ ]);
240
+ p = await pair(() => ({ action: 'decline' as const }));
241
+ await p.call('delete_kb_data', { kb_id: 'kb1', source_id: 'f1' });
242
+ expect(p.questions[0].message).toContain('"Battery jump starts.txt"');
243
+ expect(p.questions[0].message).not.toContain('f1');
244
+
245
+ const status = await p.call('kb_scrape_status', { kb_id: 'kb1' });
246
+ expect(status.body.data[0]).toMatchObject({ id: 'f1', name: 'Battery jump starts.txt', source: 'https://store.test/org/1790918471122.txt' });
247
+ expect(status.body.data[1].source).toBe('https://www.indemn.ai/?ref=1'); // a web page keeps its address
248
+ });
249
+ });
@@ -16,7 +16,7 @@ describe('MCP tool list', () => {
16
16
  expect(typeof tool.annotations?.readOnlyHint, tool.name).toBe('boolean');
17
17
  }
18
18
  expect(tools.map((t) => t.name).sort()).toMatchSnapshot();
19
- expect(tools).toHaveLength(139); // +numbers // +set_agent_transcriber // +set_org_admin_settings
19
+ expect(tools).toHaveLength(140); // +numbers // +set_agent_transcriber // +set_org_admin_settings // +remove_holiday
20
20
  const props = (name: string) => Object.keys((tools.find((t) => t.name === name)!.inputSchema as { properties?: object }).properties ?? {});
21
21
  for (const name of ['list_agents', 'delete_agent', 'set_prompt', 'list_org_members', 'list_channels', 'chat_with_agent']) {
22
22
  expect(props(name), name).toContain('org_id');