@indemn/cli 1.6.0 → 1.7.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 (311) hide show
  1. package/.claude-plugin/marketplace.json +3 -3
  2. package/.claude-plugin/plugin.json +9 -4
  3. package/.github/workflows/ci.yml +10 -0
  4. package/.github/workflows/publish.yml +2 -2
  5. package/.mcp.json +1 -4
  6. package/CONTRIBUTING.md +3 -1
  7. package/README.md +69 -176
  8. package/dist/cli/agents.d.ts.map +1 -1
  9. package/dist/cli/agents.js +226 -18
  10. package/dist/cli/agents.js.map +1 -1
  11. package/dist/cli/analytics.d.ts +3 -0
  12. package/dist/cli/analytics.d.ts.map +1 -0
  13. package/dist/cli/analytics.js +133 -0
  14. package/dist/cli/analytics.js.map +1 -0
  15. package/dist/cli/auth.d.ts.map +1 -1
  16. package/dist/cli/auth.js +62 -17
  17. package/dist/cli/auth.js.map +1 -1
  18. package/dist/cli/billing.d.ts +3 -0
  19. package/dist/cli/billing.d.ts.map +1 -0
  20. package/dist/cli/billing.js +23 -0
  21. package/dist/cli/billing.js.map +1 -0
  22. package/dist/cli/campaigns.d.ts +3 -0
  23. package/dist/cli/campaigns.d.ts.map +1 -0
  24. package/dist/cli/campaigns.js +34 -0
  25. package/dist/cli/campaigns.js.map +1 -0
  26. package/dist/cli/chat.d.ts.map +1 -1
  27. package/dist/cli/chat.js +69 -102
  28. package/dist/cli/chat.js.map +1 -1
  29. package/dist/cli/config.d.ts.map +1 -1
  30. package/dist/cli/config.js +71 -27
  31. package/dist/cli/config.js.map +1 -1
  32. package/dist/cli/conversations.d.ts +3 -0
  33. package/dist/cli/conversations.d.ts.map +1 -0
  34. package/dist/cli/conversations.js +129 -0
  35. package/dist/cli/conversations.js.map +1 -0
  36. package/dist/cli/distribution.d.ts +3 -0
  37. package/dist/cli/distribution.d.ts.map +1 -0
  38. package/dist/cli/distribution.js +122 -0
  39. package/dist/cli/distribution.js.map +1 -0
  40. package/dist/cli/eval.d.ts.map +1 -1
  41. package/dist/cli/eval.js +315 -71
  42. package/dist/cli/eval.js.map +1 -1
  43. package/dist/cli/functions.d.ts.map +1 -1
  44. package/dist/cli/functions.js +109 -38
  45. package/dist/cli/functions.js.map +1 -1
  46. package/dist/cli/index.js +63 -54
  47. package/dist/cli/index.js.map +1 -1
  48. package/dist/cli/init.d.ts +27 -0
  49. package/dist/cli/init.d.ts.map +1 -0
  50. package/dist/cli/init.js +152 -0
  51. package/dist/cli/init.js.map +1 -0
  52. package/dist/cli/kb.d.ts.map +1 -1
  53. package/dist/cli/kb.js +141 -15
  54. package/dist/cli/kb.js.map +1 -1
  55. package/dist/cli/link.d.ts +21 -0
  56. package/dist/cli/link.d.ts.map +1 -0
  57. package/dist/cli/link.js +112 -0
  58. package/dist/cli/link.js.map +1 -0
  59. package/dist/cli/org.d.ts.map +1 -1
  60. package/dist/cli/org.js +79 -67
  61. package/dist/cli/org.js.map +1 -1
  62. package/dist/cli/platform.d.ts +3 -0
  63. package/dist/cli/platform.d.ts.map +1 -0
  64. package/dist/cli/platform.js +133 -0
  65. package/dist/cli/platform.js.map +1 -0
  66. package/dist/cli/tickets.d.ts +3 -0
  67. package/dist/cli/tickets.d.ts.map +1 -0
  68. package/dist/cli/tickets.js +125 -0
  69. package/dist/cli/tickets.js.map +1 -0
  70. package/dist/cli/utils/output.d.ts +15 -9
  71. package/dist/cli/utils/output.d.ts.map +1 -1
  72. package/dist/cli/utils/output.js +53 -46
  73. package/dist/cli/utils/output.js.map +1 -1
  74. package/dist/cli/voice.d.ts +3 -0
  75. package/dist/cli/voice.d.ts.map +1 -0
  76. package/dist/cli/voice.js +206 -0
  77. package/dist/cli/voice.js.map +1 -0
  78. package/dist/core/diff.d.ts +16 -0
  79. package/dist/core/diff.d.ts.map +1 -0
  80. package/dist/core/diff.js +57 -0
  81. package/dist/core/diff.js.map +1 -0
  82. package/dist/core/org-resolver.d.ts +37 -0
  83. package/dist/core/org-resolver.d.ts.map +1 -0
  84. package/dist/core/org-resolver.js +73 -0
  85. package/dist/core/org-resolver.js.map +1 -0
  86. package/dist/core/runtime.d.ts +36 -0
  87. package/dist/core/runtime.d.ts.map +1 -0
  88. package/dist/core/runtime.js +62 -0
  89. package/dist/core/runtime.js.map +1 -0
  90. package/dist/core/scope.d.ts +33 -0
  91. package/dist/core/scope.d.ts.map +1 -0
  92. package/dist/core/scope.js +37 -0
  93. package/dist/core/scope.js.map +1 -0
  94. package/dist/mcp/registry.d.ts +67 -0
  95. package/dist/mcp/registry.d.ts.map +1 -0
  96. package/dist/mcp/registry.js +195 -0
  97. package/dist/mcp/registry.js.map +1 -0
  98. package/dist/mcp/server.d.ts +9 -0
  99. package/dist/mcp/server.d.ts.map +1 -1
  100. package/dist/mcp/server.js +87 -2
  101. package/dist/mcp/server.js.map +1 -1
  102. package/dist/mcp/tool-meta.d.ts +21 -0
  103. package/dist/mcp/tool-meta.d.ts.map +1 -0
  104. package/dist/mcp/tool-meta.js +203 -0
  105. package/dist/mcp/tool-meta.js.map +1 -0
  106. package/dist/mcp/tools.d.ts +2 -1
  107. package/dist/mcp/tools.d.ts.map +1 -1
  108. package/dist/mcp/tools.js +1462 -215
  109. package/dist/mcp/tools.js.map +1 -1
  110. package/dist/sdk/agent-bundle.d.ts +99 -0
  111. package/dist/sdk/agent-bundle.d.ts.map +1 -0
  112. package/dist/sdk/agent-bundle.js +211 -0
  113. package/dist/sdk/agent-bundle.js.map +1 -0
  114. package/dist/sdk/agents.d.ts +54 -1
  115. package/dist/sdk/agents.d.ts.map +1 -1
  116. package/dist/sdk/agents.js +59 -5
  117. package/dist/sdk/agents.js.map +1 -1
  118. package/dist/sdk/analytics.d.ts +158 -0
  119. package/dist/sdk/analytics.d.ts.map +1 -0
  120. package/dist/sdk/analytics.js +185 -0
  121. package/dist/sdk/analytics.js.map +1 -0
  122. package/dist/sdk/auth.d.ts +34 -12
  123. package/dist/sdk/auth.d.ts.map +1 -1
  124. package/dist/sdk/auth.js +62 -79
  125. package/dist/sdk/auth.js.map +1 -1
  126. package/dist/sdk/billing.d.ts +25 -0
  127. package/dist/sdk/billing.d.ts.map +1 -0
  128. package/dist/sdk/billing.js +61 -0
  129. package/dist/sdk/billing.js.map +1 -0
  130. package/dist/sdk/campaigns.d.ts +35 -0
  131. package/dist/sdk/campaigns.d.ts.map +1 -0
  132. package/dist/sdk/campaigns.js +59 -0
  133. package/dist/sdk/campaigns.js.map +1 -0
  134. package/dist/sdk/chat.d.ts +25 -2
  135. package/dist/sdk/chat.d.ts.map +1 -1
  136. package/dist/sdk/chat.js +37 -10
  137. package/dist/sdk/chat.js.map +1 -1
  138. package/dist/sdk/client.d.ts +93 -5
  139. package/dist/sdk/client.d.ts.map +1 -1
  140. package/dist/sdk/client.js +362 -100
  141. package/dist/sdk/client.js.map +1 -1
  142. package/dist/sdk/config-change.d.ts +74 -0
  143. package/dist/sdk/config-change.d.ts.map +1 -0
  144. package/dist/sdk/config-change.js +117 -0
  145. package/dist/sdk/config-change.js.map +1 -0
  146. package/dist/sdk/config.d.ts.map +1 -1
  147. package/dist/sdk/config.js +2 -1
  148. package/dist/sdk/config.js.map +1 -1
  149. package/dist/sdk/conversations.d.ts +101 -0
  150. package/dist/sdk/conversations.d.ts.map +1 -0
  151. package/dist/sdk/conversations.js +155 -0
  152. package/dist/sdk/conversations.js.map +1 -0
  153. package/dist/sdk/credentials.d.ts +25 -0
  154. package/dist/sdk/credentials.d.ts.map +1 -0
  155. package/dist/sdk/credentials.js +44 -0
  156. package/dist/sdk/credentials.js.map +1 -0
  157. package/dist/sdk/distribution.d.ts +94 -0
  158. package/dist/sdk/distribution.d.ts.map +1 -0
  159. package/dist/sdk/distribution.js +100 -0
  160. package/dist/sdk/distribution.js.map +1 -0
  161. package/dist/sdk/eval.d.ts +81 -0
  162. package/dist/sdk/eval.d.ts.map +1 -1
  163. package/dist/sdk/eval.js +117 -0
  164. package/dist/sdk/eval.js.map +1 -1
  165. package/dist/sdk/functions.d.ts +14 -1
  166. package/dist/sdk/functions.d.ts.map +1 -1
  167. package/dist/sdk/functions.js +48 -5
  168. package/dist/sdk/functions.js.map +1 -1
  169. package/dist/sdk/index.d.ts +2 -0
  170. package/dist/sdk/index.d.ts.map +1 -1
  171. package/dist/sdk/index.js +2 -0
  172. package/dist/sdk/index.js.map +1 -1
  173. package/dist/sdk/jarvis-chat.d.ts +28 -0
  174. package/dist/sdk/jarvis-chat.d.ts.map +1 -0
  175. package/dist/sdk/jarvis-chat.js +117 -0
  176. package/dist/sdk/jarvis-chat.js.map +1 -0
  177. package/dist/sdk/kb.d.ts +69 -1
  178. package/dist/sdk/kb.d.ts.map +1 -1
  179. package/dist/sdk/kb.js +111 -24
  180. package/dist/sdk/kb.js.map +1 -1
  181. package/dist/sdk/orgs.d.ts +13 -0
  182. package/dist/sdk/orgs.d.ts.map +1 -0
  183. package/dist/sdk/orgs.js +59 -0
  184. package/dist/sdk/orgs.js.map +1 -0
  185. package/dist/sdk/platform.d.ts +62 -0
  186. package/dist/sdk/platform.d.ts.map +1 -0
  187. package/dist/sdk/platform.js +95 -0
  188. package/dist/sdk/platform.js.map +1 -0
  189. package/dist/sdk/session.d.ts +25 -0
  190. package/dist/sdk/session.d.ts.map +1 -0
  191. package/dist/sdk/session.js +69 -0
  192. package/dist/sdk/session.js.map +1 -0
  193. package/dist/sdk/skills.d.ts +46 -0
  194. package/dist/sdk/skills.d.ts.map +1 -0
  195. package/dist/sdk/skills.js +203 -0
  196. package/dist/sdk/skills.js.map +1 -0
  197. package/dist/sdk/test-chat.d.ts +52 -0
  198. package/dist/sdk/test-chat.d.ts.map +1 -0
  199. package/dist/sdk/test-chat.js +134 -0
  200. package/dist/sdk/test-chat.js.map +1 -0
  201. package/dist/sdk/tickets.d.ts +98 -0
  202. package/dist/sdk/tickets.d.ts.map +1 -0
  203. package/dist/sdk/tickets.js +91 -0
  204. package/dist/sdk/tickets.js.map +1 -0
  205. package/dist/sdk/types.d.ts +91 -6
  206. package/dist/sdk/types.d.ts.map +1 -1
  207. package/dist/sdk/voice.d.ts +147 -0
  208. package/dist/sdk/voice.d.ts.map +1 -0
  209. package/dist/sdk/voice.js +236 -0
  210. package/dist/sdk/voice.js.map +1 -0
  211. package/package.json +21 -4
  212. package/scripts/check-skills.mjs +25 -0
  213. package/skills/agent-builder/SKILL.md +89 -252
  214. package/skills/evaluations/SKILL.md +23 -234
  215. package/skills/evaluations/references/analysis.md +95 -0
  216. package/skills/evaluations/references/cli-reference.md +28 -0
  217. package/skills/evaluations/references/json-formats.md +100 -0
  218. package/src/cli/agents.ts +202 -22
  219. package/src/cli/analytics.ts +138 -0
  220. package/src/cli/auth.ts +56 -18
  221. package/src/cli/billing.ts +25 -0
  222. package/src/cli/campaigns.ts +33 -0
  223. package/src/cli/chat.ts +66 -115
  224. package/src/cli/config.ts +65 -31
  225. package/src/cli/conversations.ts +133 -0
  226. package/src/cli/distribution.ts +116 -0
  227. package/src/cli/eval.ts +254 -70
  228. package/src/cli/functions.ts +105 -39
  229. package/src/cli/index.ts +58 -55
  230. package/src/cli/init.ts +154 -0
  231. package/src/cli/kb.ts +120 -17
  232. package/src/cli/link.ts +117 -0
  233. package/src/cli/org.ts +81 -75
  234. package/src/cli/platform.ts +128 -0
  235. package/src/cli/tickets.ts +130 -0
  236. package/src/cli/utils/output.ts +51 -42
  237. package/src/cli/voice.ts +204 -0
  238. package/src/core/diff.ts +52 -0
  239. package/src/core/org-resolver.ts +113 -0
  240. package/src/core/runtime.ts +73 -0
  241. package/src/core/scope.ts +64 -0
  242. package/src/mcp/registry.ts +236 -0
  243. package/src/mcp/server.ts +84 -2
  244. package/src/mcp/tool-meta.ts +241 -0
  245. package/src/mcp/tools.ts +1529 -265
  246. package/src/sdk/agent-bundle.ts +245 -0
  247. package/src/sdk/agents.ts +74 -5
  248. package/src/sdk/analytics.ts +220 -0
  249. package/src/sdk/auth.ts +89 -109
  250. package/src/sdk/billing.ts +59 -0
  251. package/src/sdk/campaigns.ts +60 -0
  252. package/src/sdk/chat.ts +44 -10
  253. package/src/sdk/client.ts +373 -102
  254. package/src/sdk/config-change.ts +148 -0
  255. package/src/sdk/config.ts +2 -1
  256. package/src/sdk/conversations.ts +199 -0
  257. package/src/sdk/credentials.ts +53 -0
  258. package/src/sdk/distribution.ts +132 -0
  259. package/src/sdk/eval.ts +140 -0
  260. package/src/sdk/functions.ts +52 -6
  261. package/src/sdk/index.ts +2 -0
  262. package/src/sdk/jarvis-chat.ts +112 -0
  263. package/src/sdk/kb.ts +116 -29
  264. package/src/sdk/orgs.ts +62 -0
  265. package/src/sdk/platform.ts +115 -0
  266. package/src/sdk/session.ts +90 -0
  267. package/src/sdk/skills.ts +188 -0
  268. package/src/sdk/test-chat.ts +151 -0
  269. package/src/sdk/tickets.ts +120 -0
  270. package/src/sdk/types.ts +88 -6
  271. package/src/sdk/voice.ts +262 -0
  272. package/test/cli/entry.test.ts +34 -0
  273. package/test/cli/init.test.ts +66 -0
  274. package/test/cli/link.test.ts +57 -0
  275. package/test/core/org-resolver.test.ts +92 -0
  276. package/test/core/runtime.test.ts +30 -0
  277. package/test/core/scope.test.ts +93 -0
  278. package/test/harness.test.ts +24 -0
  279. package/test/helpers/config-home.ts +22 -0
  280. package/test/helpers/fake-middleware.ts +56 -0
  281. package/test/helpers/fetch-recorder.ts +75 -0
  282. package/test/helpers/jwt.ts +9 -0
  283. package/test/helpers/mcp-pair.ts +38 -0
  284. package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
  285. package/test/mcp/askers.test.ts +60 -0
  286. package/test/mcp/elicit-timeout.test.ts +29 -0
  287. package/test/mcp/registry.test.ts +169 -0
  288. package/test/mcp/shaping.test.ts +41 -0
  289. package/test/mcp/tools-snapshot.test.ts +42 -0
  290. package/test/sdk/agents.test.ts +232 -0
  291. package/test/sdk/analytics.test.ts +89 -0
  292. package/test/sdk/auth.test.ts +70 -0
  293. package/test/sdk/billing.test.ts +47 -0
  294. package/test/sdk/campaigns.test.ts +43 -0
  295. package/test/sdk/client.test.ts +80 -0
  296. package/test/sdk/config-change.test.ts +142 -0
  297. package/test/sdk/conversations.test.ts +80 -0
  298. package/test/sdk/credentials.test.ts +33 -0
  299. package/test/sdk/distribution.test.ts +55 -0
  300. package/test/sdk/eval.test.ts +155 -0
  301. package/test/sdk/kb-upload.test.ts +50 -0
  302. package/test/sdk/kb.test.ts +95 -0
  303. package/test/sdk/orgs.test.ts +48 -0
  304. package/test/sdk/platform.test.ts +89 -0
  305. package/test/sdk/renewal.test.ts +115 -0
  306. package/test/sdk/session.test.ts +60 -0
  307. package/test/sdk/skills.test.ts +235 -0
  308. package/test/sdk/test-chat.test.ts +112 -0
  309. package/test/sdk/tickets.test.ts +42 -0
  310. package/test/sdk/voice.test.ts +147 -0
  311. package/vitest.config.ts +11 -0
@@ -0,0 +1,235 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
6
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
7
+ import { jwtExpiringIn } from '../helpers/jwt.js';
8
+ import { connectPair } from '../helpers/mcp-pair.js';
9
+ import { IndemnClient } from '../../src/sdk/client.js';
10
+ import { FunctionsSDK } from '../../src/sdk/functions.js';
11
+ import { assertSkillNameAllowed, proposedSkills, resolveSecretArg, skillNameKey, transferDestination } from '../../src/sdk/skills.js';
12
+ import { registerTools } from '../../src/mcp/tools.js';
13
+
14
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
15
+ const AGENT = '111111111111111111111111';
16
+
17
+ describe('skill names', () => {
18
+ it('reduces spelling variants of the FAQ retriever to one key, like copilot', () => {
19
+ expect(['FAQs Retriever', 'FQ retrievers', 'F.A.Q. retrieval tool', 'faqs-retriever'].map(skillNameKey)).toEqual(Array(4).fill('fqrtrv'));
20
+ });
21
+
22
+ it('rejects a name that imitates a built-in skill, but not the built-in itself or other names', () => {
23
+ expect(() => assertSkillNameAllowed('FAQ Retrieval Tool', 'rest_api')).toThrow(/too similar to the built-in skill/);
24
+ expect(() => assertSkillNameAllowed('faqs-retriever', 'faqs-retriever')).not.toThrow();
25
+ expect(() => assertSkillNameAllowed('Quote lookup', 'rest_api')).not.toThrow();
26
+ });
27
+ });
28
+
29
+ describe('secrets on the command line', () => {
30
+ beforeEach(() => cleanEnv());
31
+
32
+ it('reads @file and env:NAME without warning, and warns for a typed value', () => {
33
+ const dir = mkdtempSync(join(tmpdir(), 'indemn-secret-'));
34
+ writeFileSync(join(dir, 'tok'), 'from-file\n');
35
+ vi.stubEnv('MY_TOKEN', 'from-env');
36
+ const warn = vi.fn();
37
+ expect(resolveSecretArg('--auth-token', `@${join(dir, 'tok')}`, warn)).toBe('from-file');
38
+ expect(resolveSecretArg('--auth-token', 'env:MY_TOKEN', warn)).toBe('from-env');
39
+ expect(warn).not.toHaveBeenCalled();
40
+ expect(resolveSecretArg('--auth-token', 'typed', warn)).toBe('typed');
41
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('shell history'));
42
+ expect(() => resolveSecretArg('--auth-token', 'env:MISSING_VAR', warn)).toThrow(/not set/);
43
+ });
44
+ });
45
+
46
+ describe('skills SDK and tools', () => {
47
+ let home: ReturnType<typeof useTempHome>;
48
+ beforeEach(() => {
49
+ cleanEnv();
50
+ home = useTempHome();
51
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
52
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
53
+ });
54
+ afterEach(() => home.cleanup());
55
+
56
+ it('stops a reserved name before any request', async () => {
57
+ const { calls } = recordFetch();
58
+ await expect(new FunctionsSDK(new IndemnClient()).createFunction(AGENT, { type: 'rest_api', name: 'FAQs Retriever', description: 'x' })).rejects.toMatchObject({ exitCode: 2 });
59
+ expect(calls).toHaveLength(0);
60
+ });
61
+
62
+ it('MCP test_function sends the saved configuration with sample values in its placeholders', async () => {
63
+ const { calls } = recordFetch([
64
+ on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api',
65
+ configurationSchema: { endpoint: 'https://x/date', method: 'POST', payload: '{"date_str":"{{event_date}}","n":"{{other}}"}', authorization: { type: 'no-auth' } } }] } }),
66
+ on('POST', '/functions/f1/test-rest-api', { json: { statusCode: 200, response: { valid: true } } }),
67
+ ]);
68
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
69
+ await pair.call('test_function', { agent_id: AGENT, function_id: 'f1', params: { event_date: '2027-06-12' } });
70
+ expect(calls.find((c) => c.url.endsWith('/test-rest-api'))?.body).toEqual({ endpoint: 'https://x/date', method: 'POST',
71
+ payload: '{"date_str":"2027-06-12","n":"{{other}}"}', authorization: { type: 'no-auth' } });
72
+ await pair.close();
73
+ });
74
+
75
+ it('adds allowed values the way copilot stores them, and updates keep what is not changed', async () => {
76
+ const saved = { _id: 'p1', name: 'coverage', type: 'string', description: 'Which cover', is_required: true, question_title: 'Which cover?',
77
+ enums: [{ title: 'Liability', payload: 'Liability', type: 'postback' }], isEnumsEnabled: true };
78
+ const { calls } = recordFetch([
79
+ on('GET', '/functions/f1/parameters', { json: { success: true, data: [saved] } }),
80
+ on('POST', '/functions/f1/parameters', { json: { success: true, data: [saved] } }),
81
+ ]);
82
+ const sdk = new FunctionsSDK(new IndemnClient());
83
+ await sdk.addParameter(AGENT, 'f1', { name: 'coverage', type: 'string', required: true, allowedValues: ['Liability', 'Both'] });
84
+ const posts = () => calls.filter((c) => c.method === 'POST').map((c) => (c.body as unknown[])[0]);
85
+ expect(posts()[0]).toMatchObject({ isEnumsEnabled: true, enums: JSON.stringify([{ title: 'Liability', payload: 'Liability', type: 'postback' }, { title: 'Both', payload: 'Both', type: 'postback' }]) });
86
+ await sdk.updateParameter(AGENT, 'f1', 'p1', { description: 'Coverage type' });
87
+ expect(posts()[1]).toEqual({ id: 'p1', name: 'coverage', type: 'string', description: 'Coverage type', question_title: 'Which cover?', is_required: true,
88
+ enums: JSON.stringify(saved.enums), isEnumsEnabled: true });
89
+ await sdk.updateParameter(AGENT, 'f1', 'p1', { allowedValues: [] });
90
+ expect(posts()[2]).toMatchObject({ enums: '[]', isEnumsEnabled: false, is_required: true, description: 'Which cover' });
91
+ });
92
+
93
+ it('saves call-transfer destinations the way the dashboard does, and a new one replaces the old', async () => {
94
+ expect(transferDestination({}, { handoffNumber: '+15551230000' })).toEqual({ phoneNumber: '+15551230000', intlTelObj: { number: '+15551230000', e164Number: '+15551230000' } });
95
+ expect(transferDestination({}, { payload: JSON.stringify({ action: 'transfer', payload: { to: '+15551230001' } }), payloadOps: [] }))
96
+ .toEqual({ phoneNumber: '+15551230001', intlTelObj: { number: '+15551230001', e164Number: '+15551230001' } });
97
+ expect(transferDestination({ sipUri: 'sip:old@x', sipHeaders: { a: '1' }, outputInstructions: 'k' }, { phoneNumber: '+15551230002' }))
98
+ .toEqual({ outputInstructions: 'k', phoneNumber: '+15551230002', intlTelObj: { number: '+15551230002', e164Number: '+15551230002' } });
99
+ expect(transferDestination({ phoneNumber: '+15551230003', intlTelObj: {} }, { sipUri: 'sip:desk@x' })).toEqual({ sipUri: 'sip:desk@x' });
100
+ });
101
+
102
+ it('bulk-creates with the body copilot expects', async () => {
103
+ const { calls } = recordFetch([on('POST', '/functions/bulk-create', { json: { success: true, data: [{ _id: 'f1' }] } })]);
104
+ await new FunctionsSDK(new IndemnClient()).bulkCreate(AGENT, [{ type: 'rest_api', name: 'quote', description: 'q' }]);
105
+ expect(calls[0].body).toEqual({ functions: [{ type: 'rest_api', name: 'quote', description: 'q' }] });
106
+ });
107
+
108
+ it('finds proposed skills wherever the answer nests them', () => {
109
+ expect(proposedSkills({ data: { functions: [{ name: 'a' }] } })).toEqual([{ name: 'a' }]);
110
+ expect(proposedSkills({ nothing: true })).toBeNull();
111
+ });
112
+
113
+ it('MCP create_function saves endpoint, method and body in a second step; update_function keeps the rest', async () => {
114
+ const { calls } = recordFetch([
115
+ on('POST', `/bots/${AGENT}/functions`, { json: { success: true, data: { _id: 'f1', type: 'rest_api' } } }),
116
+ on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api', description: 'Checks a date',
117
+ configurationSchema: { endpoint: 'https://x/date', method: 'POST', authorization: { type: 'bearer-token', token: 't' } } }] } }),
118
+ on('PUT', `/bots/${AGENT}/functions/f1`, { json: { success: true, data: { _id: 'f1' } } }),
119
+ ]);
120
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
121
+ await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'validate_date', description: 'Checks a date', url: 'https://x/date', method: 'post', payload: { date_str: '{{event_date}}' } });
122
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ type: 'rest_api', name: 'validate_date', description: 'Checks a date' });
123
+ expect(calls.filter((c) => c.method === 'PUT')[0].body).toEqual({ type: 'rest_api', description: 'Checks a date',
124
+ configurationSchema: { endpoint: 'https://x/date', method: 'POST', payload: '{"date_str":"{{event_date}}"}', authorization: { type: 'no-auth' }, craftResponse: true } });
125
+ await pair.call('update_function', { agent_id: AGENT, function_id: 'f1', response_field: 'data.valid' });
126
+ expect(calls.filter((c) => c.method === 'PUT')[1].body).toEqual({ type: 'rest_api', description: 'Checks a date',
127
+ configurationSchema: { endpoint: 'https://x/date', method: 'POST', authorization: { type: 'bearer-token', token: 't' }, responseField: 'data.valid' } });
128
+ await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'said', url: 'https://x/a', method: 'GET', output_instructions: 'Your quote is {{response.total}}.' });
129
+ expect(calls.filter((c) => c.method === 'PUT')[2].body.configurationSchema).toMatchObject({ outputInstructions: 'Your quote is {{response.total}}.' });
130
+ expect(calls.filter((c) => c.method === 'PUT')[2].body.configurationSchema.craftResponse).toBeUndefined();
131
+ const missing = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'no_url' });
132
+ expect(missing.isError).toBe(true);
133
+ await pair.close();
134
+ });
135
+
136
+ it('MCP skill tools take typed REST auth and JSON text for free-form objects (as Gemini sends them)', async () => {
137
+ const NEW = 'fn-new';
138
+ const { calls } = recordFetch([
139
+ on('POST', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: { _id: NEW, type: 'rest_api', name: 'get_quote' } } }),
140
+ on('PUT', `/functions/${NEW}`, { json: { success: true, data: {} } }),
141
+ ]);
142
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
143
+ const res = await pair.call('create_function', {
144
+ agent_id: AGENT, type: 'rest_api', name: 'get_quote', url: 'https://api.example.com/quote', method: 'POST',
145
+ auth_type: 'bearer-token', auth_token: 'abc', auth_key: '', auth_username: '',
146
+ payload: '{"event_date": "{{event_date}}", "guests": {{guests}}}',
147
+ configuration: '{"outputInstructions": "Your premium is {{response.premium}}"}',
148
+ });
149
+ expect(res.isError).toBeFalsy();
150
+ const saved = calls.find((c) => c.method === 'PUT')?.body.configurationSchema;
151
+ expect(saved).toMatchObject({ endpoint: 'https://api.example.com/quote', method: 'POST', outputInstructions: 'Your premium is {{response.premium}}' });
152
+ expect(saved.authorization).toEqual({ type: 'bearer-token', token: 'abc' }); // only the fields of its type
153
+ expect(saved.payload).toBe('{"event_date": "{{event_date}}", "guests": "{{guests}}"}'); // bare placeholder quoted, as the dashboard does
154
+ await pair.close();
155
+ });
156
+
157
+ it('MCP create_function refuses a second skill with the same name; skill tools take the tool name too', async () => {
158
+ const existing = { _id: 'f1', toolName: 'lc_abc', name: 'get_quote', type: 'rest_api', configurationSchema: { endpoint: 'https://x', method: 'GET' } };
159
+ const { calls } = recordFetch([
160
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] }),
161
+ on('GET', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: [existing] } }),
162
+ on('DELETE', '/functions/f1', { json: { success: true } }),
163
+ ]);
164
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
165
+ const dup = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'Get_Quote', url: 'https://y', method: 'POST' });
166
+ expect(dup.isError).toBe(true);
167
+ expect(String(dup.body)).toMatch(/already has a skill named "Get_Quote" \(function_id f1\)\. Change it with update_function/);
168
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
169
+ const ghost = await pair.call('delete_function', { agent_id: AGENT, function_id: 'lc_abc', confirm: true });
170
+ expect(ghost.isError).toBe(true);
171
+ expect(String(ghost.body)).toMatch(/Not found: skill lc_abc/);
172
+ expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
173
+ const del = await pair.call('delete_function', { agent_id: AGENT, function_id: 'f1', confirm: true });
174
+ expect(del.body.data).toMatchObject({ deleted: true, function_id: 'f1', name: 'get_quote' });
175
+ await pair.close();
176
+ });
177
+
178
+ it('MCP create_function saves nothing for a body that is not JSON, and removes the skill when copilot refuses its configuration', async () => {
179
+ const { calls } = recordFetch([
180
+ on('POST', `/ai-studio/bots/${AGENT}/functions`, { json: { success: true, data: { _id: 'fx', type: 'rest_api', name: 'quote' } } }),
181
+ on('PUT', '/functions/fx', { status: 500, json: { error: 'Payload must be a valid JSON string.' } }),
182
+ on('DELETE', '/functions/fx', { json: { success: true } }),
183
+ ]);
184
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
185
+ const bad = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST', payload: '{"a": 1,}' });
186
+ expect(bad.isError).toBe(true);
187
+ expect(String(bad.body)).toMatch(/request body is not valid JSON.*Nothing was saved/);
188
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
189
+ const refused = await pair.call('create_function', { agent_id: AGENT, type: 'rest_api', name: 'quote', url: 'https://x', method: 'POST' });
190
+ expect(String(refused.body)).toMatch(/Nothing was saved: the skill's configuration was refused/);
191
+ expect(calls.filter((c) => c.method === 'DELETE').map((c) => c.url)).toEqual([expect.stringContaining('/functions/fx')]);
192
+ await pair.close();
193
+ });
194
+
195
+ it('MCP import_functions warns that everything is replaced and waits for confirmation', async () => {
196
+ const { calls } = recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] })]);
197
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
198
+ const res = await pair.call('import_functions', { agent_id: AGENT, url: 'https://x/skills.json' });
199
+ expect(res.body).toMatchObject({ dry_run: true });
200
+ expect(res.body.would).toMatch(/DELETE every skill .* built-in handoff and FAQ retriever/);
201
+ expect(calls.filter((c) => c.method !== 'GET')).toHaveLength(0); // only name lookups
202
+ await pair.close();
203
+ });
204
+
205
+ it('MCP update_function and update_agent ask first when the agent may be live, and not for a prototype', async () => {
206
+ let lifecycle = 'published';
207
+ const { calls } = recordFetch([
208
+ on('GET', `/v1-agents/${AGENT}`, () => ({ json: { id: AGENT, name: 'Bot', lifecycle } })),
209
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot' }] }),
210
+ on('GET', `/bots/${AGENT}/functions`, { json: { success: true, data: [{ _id: 'f1', type: 'rest_api', description: 'Checks a date',
211
+ configurationSchema: { endpoint: 'https://x/date', method: 'POST', authorization: { type: 'bearer-token', token: 't' } } }] } }),
212
+ on('PUT', `/bots/${AGENT}/functions/f1`, { json: { success: true, data: { _id: 'f1' } } }),
213
+ on('PUT', `/bots/${AGENT}`, { json: { success: true, data: { _id: AGENT } } }),
214
+ on('PATCH', `/bots/${AGENT}`, { json: { success: true, data: { _id: AGENT } } }),
215
+ ]);
216
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
217
+ const writes = () => calls.filter((c) => c.method === 'PUT' || c.method === 'PATCH').length;
218
+
219
+ const skill = await pair.call('update_function', { agent_id: AGENT, function_id: 'f1', url: 'https://evil/x', auth_token: 's3cret' });
220
+ expect(skill.body.dry_run).toBe(true);
221
+ expect(skill.body.would).toMatch(/of agent "Bot": url, auth_token\. The agent is PUBLISHED \(live for customers\)/);
222
+ expect(skill.body.would).not.toContain('s3cret');
223
+ const hook = await pair.call('update_agent', { agent_id: AGENT, webhook_url: 'https://evil/hook' });
224
+ expect(hook.body.dry_run).toBe(true);
225
+ expect(writes()).toBe(0);
226
+
227
+ await pair.call('update_function', { agent_id: AGENT, function_id: 'f1', url: 'https://x/v2', confirm: true });
228
+ expect(writes()).toBe(1);
229
+
230
+ lifecycle = 'prototype';
231
+ await pair.call('update_function', { agent_id: AGENT, function_id: 'f1', url: 'https://x/v3' });
232
+ expect(writes()).toBe(2);
233
+ await pair.close();
234
+ });
235
+ });
@@ -0,0 +1,112 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { on, recordFetch, type Responder } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
4
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
+ import { startFakeMiddleware, type FakeMiddleware } from '../helpers/fake-middleware.js';
6
+ import { connectPair } from '../helpers/mcp-pair.js';
7
+ import { IndemnClient } from '../../src/sdk/client.js';
8
+ import { ChatSession } from '../../src/sdk/chat.js';
9
+ import { ConversationsSDK } from '../../src/sdk/conversations.js';
10
+ import { ensurePreview, exchange, openTestChat } from '../../src/sdk/test-chat.js';
11
+ import { registerTools } from '../../src/mcp/tools.js';
12
+
13
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
14
+ const OTHER = 'bbbbbbbbbbbbbbbbbbbbbbbb';
15
+ const AGENT = '111111111111111111111111';
16
+ const PROJ = '222222222222222222222222';
17
+
18
+ const agentOk = on('GET', `/ai-studio/v2/bots/${AGENT}`, { json: { success: true, data: [{ _id: AGENT, name: 'Quote Bot', id_project: PROJ }] } });
19
+ const previewExisting = on('GET', '/preview/agent', { json: { success: true, data: { _id: 'prev1' } } });
20
+ const stored = (over: Record<string, unknown> = {}): Responder =>
21
+ on('GET', `/${PROJ}/requests/`, (c) => ({ json: { _id: 'r1', request_id: decodeURIComponent(c.url.split('/requests/')[1]), id_project: PROJ, id_organization: ORG, isTestMode: true, attributes: { isTestMode: true }, ...over } }));
22
+
23
+ describe('CLI chats are always test conversations', () => {
24
+ let home: ReturnType<typeof useTempHome>;
25
+ let mw: FakeMiddleware;
26
+ beforeEach(async () => {
27
+ cleanEnv();
28
+ home = useTempHome();
29
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
30
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', email: 'qa@indemn.ai', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
31
+ mw = await startFakeMiddleware();
32
+ vi.stubEnv('INDEMN_MIDDLEWARE_URL', mw.url);
33
+ });
34
+ afterEach(async () => { await mw.close(); home.cleanup(); });
35
+
36
+ it('a caller cannot turn test mode off', () => {
37
+ const s = new ChatSession(AGENT, 'http://x', undefined, { tester: 't@x', preview: 'p1', customData: { isTestMode: false, other: 1 } });
38
+ expect(s.getCustomData()).toEqual({ other: 1, conversation_tester: 't@x', preview: 'p1', isTestMode: true });
39
+ });
40
+
41
+ it('opens with isTestMode, the tester email and the preview record, then reads it back', async () => {
42
+ const { calls } = recordFetch([agentOk, previewExisting, stored()]);
43
+ const chat = await openTestChat(new IndemnClient(), AGENT);
44
+ expect(mw.sessionRequests[0].customData).toEqual({ conversation_tester: 'qa@indemn.ai', preview: 'prev1', isTestMode: true });
45
+ expect(calls.some((c) => c.method === 'POST' && c.url.includes('/preview/agent'))).toBe(false);
46
+ expect(calls.some((c) => c.url.endsWith(`/${PROJ}/requests/sess-1`))).toBe(true);
47
+ expect(chat.url).toBe(`https://devcopilot.indemn.ai/#/project/${PROJ}/wsrequest/sess-1/messages?view=single`);
48
+ await chat.session.disconnect();
49
+ });
50
+
51
+ it('opens with the agent\'s saved greeting, as the dashboard preview does', async () => {
52
+ const greeting = { text: 'Welcome to Acme renewals!', quick_replies: [] };
53
+ recordFetch([agentOk, previewExisting, stored(), on('GET', `/bots/${AGENT}/configurations`, { json: { success: true, data: { first_message: greeting } } })]);
54
+ const chat = await openTestChat(new IndemnClient(), AGENT);
55
+ expect(mw.sessionRequests[0].customData).toEqual({ first_message: greeting, conversation_tester: 'qa@indemn.ai', preview: 'prev1', isTestMode: true });
56
+ await chat.session.disconnect();
57
+ });
58
+
59
+ it('stops before any message when the platform stored it as live', async () => {
60
+ recordFetch([agentOk, previewExisting, stored({ isTestMode: false })]);
61
+ await expect(openTestChat(new IndemnClient(), AGENT)).rejects.toThrow(/stored as a live conversation.*Nothing was sent/);
62
+ await new Promise((r) => setTimeout(r, 50));
63
+ expect(mw.userMessages).toHaveLength(0);
64
+ expect(mw.closed).toEqual([{ session_id: 'sess-1', reason: 'client_close_conversation' }]);
65
+ });
66
+
67
+ it('stops before any message when it landed in another org', async () => {
68
+ recordFetch([agentOk, previewExisting, stored({ id_organization: OTHER })]);
69
+ await expect(openTestChat(new IndemnClient(), AGENT)).rejects.toThrow(`stored in org ${OTHER}`);
70
+ expect(mw.userMessages).toHaveLength(0);
71
+ });
72
+
73
+ it('waits for the conversation to appear, then accepts it', async () => {
74
+ let tries = 0;
75
+ recordFetch([on('GET', '/requests/', () => (++tries < 3 ? { status: 404, text: 'nope' } : { json: { request_id: 's', isTestMode: true, id_organization: ORG } }))]);
76
+ await new ConversationsSDK(new IndemnClient()).verifyTestConversation(PROJ, 's', ORG, { intervalMs: 5 });
77
+ expect(tries).toBe(3);
78
+ });
79
+
80
+ it('creates a preview record only when the person has none', async () => {
81
+ const { calls } = recordFetch([
82
+ on('GET', '/preview/agent', { json: { success: true, data: null } }),
83
+ on('POST', '/preview/agent', { json: { success: true, data: { _id: 'prev9' } } }),
84
+ ]);
85
+ await expect(ensurePreview(new IndemnClient({ orgId: ORG }), AGENT)).resolves.toBe('prev9');
86
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({});
87
+ });
88
+
89
+ it('sends a message and returns the greeting and the reply', async () => {
90
+ recordFetch([agentOk, previewExisting, stored()]);
91
+ const chat = await openTestChat(new IndemnClient(), AGENT);
92
+ const out = await exchange(chat, 'hi', { settleMs: 50 });
93
+ expect(out).toEqual({ greeting: 'Hello there', response: 'echo: hi' });
94
+ await chat.session.disconnect();
95
+ });
96
+
97
+ it('MCP chat keeps the conversation open for the next turn unless asked to end it', async () => {
98
+ recordFetch([agentOk, previewExisting, stored()]);
99
+ const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
100
+ const first = await pair.call('chat_with_agent', { agent_id: AGENT, message: 'one' });
101
+ expect(first.body.data).toMatchObject({ session_id: 'sess-1', test_conversation: true, tester: 'qa@indemn.ai', response: 'echo: one' });
102
+ expect(first.body.context.org_id).toBe(ORG);
103
+ await new Promise((r) => setTimeout(r, 50));
104
+ expect(mw.closed).toHaveLength(0);
105
+
106
+ const second = await pair.call('chat_with_agent', { agent_id: AGENT, message: 'two', session_id: 'sess-1', end_conversation: true });
107
+ expect(second.body.data.response).toBe('echo: two');
108
+ await new Promise((r) => setTimeout(r, 50));
109
+ expect(mw.closed).toEqual([{ session_id: 'sess-1', reason: 'client_close_conversation' }]);
110
+ await pair.close();
111
+ }, 15_000);
112
+ });
@@ -0,0 +1,42 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
4
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
+ import { IndemnClient } from '../../src/sdk/client.js';
6
+ import { TicketsSDK } from '../../src/sdk/tickets.js';
7
+
8
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
9
+
10
+ describe('tickets', () => {
11
+ let home: ReturnType<typeof useTempHome>;
12
+ beforeEach(() => {
13
+ cleanEnv();
14
+ home = useTempHome();
15
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
16
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
17
+ });
18
+ afterEach(() => home.cleanup());
19
+ const sdk = () => new TicketsSDK(new IndemnClient());
20
+
21
+ it('reports similar tickets instead of pretending a ticket was created', async () => {
22
+ recordFetch([on('POST', '/tickets', { json: { success: true, saved: false, similar: [{ _id: 't9', title: 'Same issue' }] } })]);
23
+ expect(await sdk().create({ title: 'Broken quote' })).toMatchObject({ saved: false, similar: [{ _id: 't9' }] });
24
+ });
25
+
26
+ it('creates with force and returns the ticket', async () => {
27
+ const { calls } = recordFetch([on('POST', '/tickets', { json: { success: true, saved: true, data: { _id: 't1', title: 'Broken quote' } } })]);
28
+ expect(await sdk().create({ title: 'Broken quote' }, { force: true })).toEqual({ saved: true, ticket: { _id: 't1', title: 'Broken quote' } });
29
+ expect(calls[0].body).toEqual({ title: 'Broken quote', force: true });
30
+ });
31
+
32
+ it('lists with comma-separated statuses', async () => {
33
+ const { calls } = recordFetch([on('GET', '/tickets', { json: { success: true, data: [] } })]);
34
+ await sdk().list({ status: ['not_started', 'in_progress'] });
35
+ expect(new URL(calls[0].url).searchParams.get('status')).toBe('not_started,in_progress');
36
+ });
37
+
38
+ it('reports rule caveats instead of saving an overlapping rule', async () => {
39
+ recordFetch([on('POST', '/ticket-creation-rules', { json: { success: true, saved: false, caveats: ['overlaps rule A'], suggested_name: 'Refunds' } })]);
40
+ expect(await sdk().saveRule({ prompt: 'refund requests' })).toEqual({ saved: false, caveats: ['overlaps rule A'], suggested_name: 'Refunds' });
41
+ });
42
+ });
@@ -0,0 +1,147 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { on, recordFetch } from '../helpers/fetch-recorder.js';
3
+ import { cleanEnv, useTempHome } from '../helpers/config-home.js';
4
+ import { jwtExpiringIn } from '../helpers/jwt.js';
5
+ import { IndemnClient } from '../../src/sdk/client.js';
6
+ import { VoiceSDK } from '../../src/sdk/voice.js';
7
+
8
+ const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
9
+ const AGENT = '111111111111111111111111';
10
+ const FOREIGN = '999999999999999999999999';
11
+ const agents = [
12
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot', channels: [{ type: 'VOICE' }] }] }),
13
+ on('GET', `/v2/bots/${FOREIGN}`, { status: 404, text: 'nope' }),
14
+ ];
15
+
16
+ describe('voice', () => {
17
+ let home: ReturnType<typeof useTempHome>;
18
+ beforeEach(() => {
19
+ cleanEnv();
20
+ home = useTempHome();
21
+ vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
22
+ IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
23
+ });
24
+ afterEach(() => home.cleanup());
25
+ const sdk = () => new VoiceSDK(new IndemnClient());
26
+
27
+ it('searches available numbers like the dashboard and shows five by default', async () => {
28
+ const offered = Array.from({ length: 7 }, (_, i) => ({ phoneNumber: `+1331204491${i}`, friendlyName: `(331) 204-491${i}`, locality: 'Aurora', region: 'IL' }));
29
+ const { calls } = recordFetch([on('GET', '/voice/configurations/numbers/local', { json: { success: true, data: offered } })]);
30
+ const rows = await sdk().searchNumbers({ areaCode: '(331)', contains: '204' });
31
+ expect(rows).toHaveLength(5);
32
+ const url = new URL(calls[0].url);
33
+ expect(Object.fromEntries(url.searchParams)).toEqual({ countryCode: 'US', limit: '5', areaCode: '331', pattern: '204' });
34
+ await expect(sdk().searchNumbers({ country: 'FR' })).rejects.toThrow(/US, CA, GB, AU/);
35
+ });
36
+
37
+ it('buys a still-available number for a voice agent with the Twilio record, and refuses the unsafe cases first', async () => {
38
+ const pick = { phoneNumber: '+13312044919', friendlyName: '(331) 204-4919', locality: 'Aurora', region: 'IL' };
39
+ const { calls } = recordFetch([
40
+ ...agents,
41
+ on('GET', '/v2/bots/222222222222222222222222', { json: [{ _id: '222222222222222222222222', name: 'Web', channels: [{ type: 'WEB' }] }] }),
42
+ on('GET', '/voice/configurations/numbers/local', { json: { success: true, data: [pick] } }),
43
+ on('GET', '/voice/configurations/numbers', { json: { success: true, data: [{ phoneNumber: '+13310000000' }] } }),
44
+ on('GET', `/bots/${AGENT}/configurations/voice/numbers`, { json: { success: true, data: [] } }),
45
+ on('POST', '/configurations/voice/numbers', { json: { success: true, data: { phoneNumber: pick.phoneNumber } } }),
46
+ ]);
47
+ await expect(sdk().buyNumber('222222222222222222222222', '(331) 204-4919')).rejects.toThrow(/does not answer the phone/);
48
+ await expect(sdk().buyNumber(AGENT, '(331) 000-0000')).rejects.toThrow(/already owns/);
49
+ await expect(sdk().buyNumber(AGENT, '(331) 204-1111')).rejects.toThrow(/no longer available/);
50
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
51
+ const res = await sdk().buyNumber(AGENT, '(331) 204-4919');
52
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ type: 'local', friendlyName: '(331) 204-4919', phoneNumber: '+13312044919', payload: pick });
53
+ expect(res).toMatchObject({ phone_number: '+13312044919', locality: 'Aurora', agent: 'Bot' });
54
+ });
55
+
56
+ it('refuses to set the voice of another org\'s agent before any write', async () => {
57
+ const { calls } = recordFetch(agents);
58
+ await expect(sdk().setAgentVoice(FOREIGN, { provider: 'cartesia', voice_id: 'v', name: 'n', gender: 'g', language: 'en', countryCode: 'US' })).rejects.toThrow(/not in the organization/);
59
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
60
+ });
61
+
62
+ it('explains that an agent without a phone number has no voice settings', async () => {
63
+ recordFetch([...agents, on('GET', '/configurations/voice/numbers', { json: { success: true, data: [] } })]);
64
+ await expect(sdk().getAgentVoice(AGENT)).rejects.toMatchObject({ exitCode: 2 });
65
+ });
66
+
67
+ it('never buys a number: an unowned number is refused before the attach call', async () => {
68
+ const { calls } = recordFetch([...agents, on('GET', '/voice/configurations/numbers', { json: { success: true, data: [{ phoneNumber: '+15550001', is_active: false, type: 'local' }] } })]);
69
+ await expect(sdk().attachNumber(AGENT, '+15559999')).rejects.toThrow(/not a number this org owns/);
70
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
71
+ });
72
+
73
+ it('moves a number between agents even when copilot lists no owned numbers, and still refuses unknown ones', async () => {
74
+ const OTHER = '222222222222222222222222';
75
+ const { calls } = recordFetch([
76
+ ...agents,
77
+ on('GET', `/v2/bots/${OTHER}`, { json: [{ _id: OTHER, name: 'New', channels: [{ type: 'VOICE' }] }] }),
78
+ on('GET', `/bots/${AGENT}/configurations/voice/numbers`, { json: { success: true, data: [{ phoneNumber: '+15550001', type: 'local', friendlyName: '(555) 000-1', additionalDetails: { locality: 'Chicago' } }] } }),
79
+ on('GET', `/bots/${OTHER}/configurations/voice/numbers`, { json: { success: true, data: [] } }),
80
+ on('DELETE', '/configurations/voice/numbers', { json: { success: true, data: {} } }),
81
+ // copilot's owned-numbers list comes back empty when the org is served from its cache
82
+ on('GET', '/voice/configurations/numbers', { json: { success: true, data: [] } }),
83
+ on('POST', '/configurations/voice/numbers', { json: { success: true, data: {} } }),
84
+ ]);
85
+ const voice = sdk();
86
+ await expect(voice.attachNumber(OTHER, '+15559999')).rejects.toThrow(/not a number this org owns/);
87
+ await voice.detachNumber(AGENT, '+15550001');
88
+ await voice.attachNumber(OTHER, '+15550001');
89
+ expect(calls.filter((c) => c.method === 'POST').map((c) => c.body)).toEqual([
90
+ { type: 'local', phoneNumber: '+15550001', friendlyName: '(555) 000-1', payload: { locality: 'Chicago' } },
91
+ ]);
92
+ });
93
+
94
+ it('attaches an owned, free number with its own type', async () => {
95
+ const { calls } = recordFetch([
96
+ ...agents,
97
+ on('GET', '/voice/configurations/numbers', { json: { success: true, data: [{ phoneNumber: '+15550001', is_active: false, type: 'toll-free' }] } }),
98
+ on('POST', '/configurations/voice/numbers', { json: { success: true, data: {} } }),
99
+ ]);
100
+ await sdk().attachNumber(AGENT, '+15550001');
101
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ type: 'toll-free', phoneNumber: '+15550001' });
102
+ });
103
+
104
+ it('marks a voice preview as a test call, like the dashboard', async () => {
105
+ const { calls } = recordFetch([...agents, on('GET', `/bots/${AGENT}/configurations/voice/numbers`, { json: { success: true, data: [{ phoneNumber: '+15550001' }] } }),
106
+ on('POST', '/livekit/token', { json: { token: 't', server_url: 'wss://lk', room_name: 'call_x' } })]);
107
+ const res = await sdk().voicePreview(AGENT);
108
+ const body = calls.find((c) => c.url.endsWith('/livekit/token'))!.body as { bot_id: string; user_attributes: Record<string, string> };
109
+ expect(body.bot_id).toBe(AGENT);
110
+ expect(body.user_attributes.isTestMode).toBe('true');
111
+ expect(body.user_attributes['sip.twilio.callSid']).toMatch(/^CA[0-9A-F]{32}$/);
112
+ expect(res.join_url).toBe('https://meet.livekit.io/custom?liveKitUrl=wss%3A%2F%2Flk&token=t');
113
+ expect(body.user_attributes['sip.trunkPhoneNumber']).toBe('+15550001');
114
+ });
115
+
116
+ it('refuses a test call for an agent without a number (it would never join)', async () => {
117
+ const { calls } = recordFetch([...agents, on('GET', '/configurations/voice/numbers', { json: { success: true, data: [] } })]);
118
+ await expect(sdk().voicePreview(AGENT)).rejects.toThrow(/no phone number/);
119
+ expect(calls.some((c) => c.url.endsWith('/livekit/token'))).toBe(false);
120
+ });
121
+
122
+ it('keeps the saved speed and sends the country in lower case', async () => {
123
+ const { calls } = recordFetch([...agents, on('GET', '/configurations/voice/numbers', { json: { success: true, data: [{ phoneNumber: '+15550001', modelSettings: { tts: { settings: { speed: 1.2 } } } }] } }),
124
+ on('POST', '/voices', { json: { success: true, data: {} } })]);
125
+ await sdk().setAgentVoice(AGENT, { provider: 'cartesia', voice_id: 'v', name: 'n', gender: 'g', language: 'en', countryCode: 'US' });
126
+ expect(calls.find((c) => c.method === 'POST')?.body).toMatchObject({ countryCode: 'us', settings: { speed: 1.2 } });
127
+ });
128
+
129
+ it('sets the transcriber with key terms the way the dashboard does', async () => {
130
+ const { calls } = recordFetch([...agents, on('GET', '/configurations/voice/numbers', { json: { success: true, data: [{ phoneNumber: '+15550001' }] } }),
131
+ on('POST', '/voices', { json: { success: true, data: {} } })]);
132
+ await sdk().setAgentTranscriber(AGENT, { provider: 'deepgram', model: 'flux-general-en', language: 'en', keyterms: [' Markel ', 'EventGuard'] });
133
+ expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ stt: { provider: 'deepgram', model: 'flux-general-en', language: 'en', config: { keyterm: ['Markel', 'EventGuard'] } } });
134
+ await expect(sdk().setAgentTranscriber(AGENT, { provider: 'd', model: 'm', language: 'en', keyterms: ['x'.repeat(101)] })).rejects.toThrow(/100 characters/);
135
+ });
136
+
137
+ it('refuses to attach a second number or a number to an agent that does not answer the phone', async () => {
138
+ const { calls } = recordFetch([
139
+ on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot', channels: [{ type: 'VOICE' }] }] }),
140
+ on('GET', '/v2/bots/222222222222222222222222', { json: [{ _id: '222222222222222222222222', name: 'Web', channels: [{ type: 'WEB' }] }] }),
141
+ on('GET', `/bots/${AGENT}/configurations/voice/numbers`, { json: { success: true, data: [{ phoneNumber: '+15550002' }] } }),
142
+ ]);
143
+ await expect(sdk().attachNumber(AGENT, '+15550001')).rejects.toThrow(/already has \+15550002/);
144
+ await expect(sdk().attachNumber('222222222222222222222222', '+15550001')).rejects.toThrow(/does not answer the phone/);
145
+ expect(calls.some((c) => c.method === 'POST')).toBe(false);
146
+ });
147
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ['test/**/*.test.ts'],
6
+ environment: 'node',
7
+ restoreMocks: true,
8
+ unstubGlobals: true,
9
+ unstubEnvs: true,
10
+ },
11
+ });