@indemn/cli 1.7.1 → 1.7.2
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/mcp/registry.d.ts.map +1 -1
- package/dist/mcp/registry.js +6 -4
- package/dist/mcp/registry.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts.map +1 -1
- package/dist/mcp/tool-meta.js +35 -9
- package/dist/mcp/tool-meta.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +232 -49
- package/dist/mcp/tools.js.map +1 -1
- package/dist/report/styles.d.ts +1 -0
- package/dist/report/styles.d.ts.map +1 -1
- package/dist/report/styles.js +5 -1
- package/dist/report/styles.js.map +1 -1
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +11 -1
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +4 -0
- package/dist/sdk/config-change.d.ts.map +1 -1
- package/dist/sdk/config-change.js +27 -5
- package/dist/sdk/config-change.js.map +1 -1
- package/dist/sdk/kb.d.ts +12 -0
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +45 -5
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/voice.js +5 -5
- package/dist/sdk/voice.js.map +1 -1
- package/package.json +2 -2
- package/src/mcp/registry.ts +9 -4
- package/src/mcp/tool-meta.ts +35 -9
- package/src/mcp/tools.ts +235 -49
- package/src/report/styles.ts +5 -1
- package/src/sdk/client.ts +5 -1
- package/src/sdk/config-change.ts +27 -5
- package/src/sdk/kb.ts +49 -8
- package/src/sdk/voice.ts +5 -5
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +1 -0
- package/test/mcp/darwin-fixes.test.ts +231 -0
- package/test/mcp/tools-snapshot.test.ts +1 -1
package/src/sdk/config-change.ts
CHANGED
|
@@ -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
|
-
|
|
82
|
-
llm = await modelForAgent(client,
|
|
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') &&
|
|
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
|
|
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
|
|
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}`,
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 } : {}) },
|
|
@@ -0,0 +1,231 @@
|
|
|
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
|
+
});
|
|
@@ -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(
|
|
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');
|