@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.
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +9 -4
- package/.github/workflows/ci.yml +10 -0
- package/.github/workflows/publish.yml +2 -2
- package/.mcp.json +1 -4
- package/CONTRIBUTING.md +3 -1
- package/README.md +69 -176
- package/dist/cli/agents.d.ts.map +1 -1
- package/dist/cli/agents.js +226 -18
- package/dist/cli/agents.js.map +1 -1
- package/dist/cli/analytics.d.ts +3 -0
- package/dist/cli/analytics.d.ts.map +1 -0
- package/dist/cli/analytics.js +133 -0
- package/dist/cli/analytics.js.map +1 -0
- package/dist/cli/auth.d.ts.map +1 -1
- package/dist/cli/auth.js +62 -17
- package/dist/cli/auth.js.map +1 -1
- package/dist/cli/billing.d.ts +3 -0
- package/dist/cli/billing.d.ts.map +1 -0
- package/dist/cli/billing.js +23 -0
- package/dist/cli/billing.js.map +1 -0
- package/dist/cli/campaigns.d.ts +3 -0
- package/dist/cli/campaigns.d.ts.map +1 -0
- package/dist/cli/campaigns.js +34 -0
- package/dist/cli/campaigns.js.map +1 -0
- package/dist/cli/chat.d.ts.map +1 -1
- package/dist/cli/chat.js +69 -102
- package/dist/cli/chat.js.map +1 -1
- package/dist/cli/config.d.ts.map +1 -1
- package/dist/cli/config.js +71 -27
- package/dist/cli/config.js.map +1 -1
- package/dist/cli/conversations.d.ts +3 -0
- package/dist/cli/conversations.d.ts.map +1 -0
- package/dist/cli/conversations.js +129 -0
- package/dist/cli/conversations.js.map +1 -0
- package/dist/cli/distribution.d.ts +3 -0
- package/dist/cli/distribution.d.ts.map +1 -0
- package/dist/cli/distribution.js +122 -0
- package/dist/cli/distribution.js.map +1 -0
- package/dist/cli/eval.d.ts.map +1 -1
- package/dist/cli/eval.js +315 -71
- package/dist/cli/eval.js.map +1 -1
- package/dist/cli/functions.d.ts.map +1 -1
- package/dist/cli/functions.js +109 -38
- package/dist/cli/functions.js.map +1 -1
- package/dist/cli/index.js +63 -54
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.d.ts +27 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +152 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/kb.d.ts.map +1 -1
- package/dist/cli/kb.js +141 -15
- package/dist/cli/kb.js.map +1 -1
- package/dist/cli/link.d.ts +21 -0
- package/dist/cli/link.d.ts.map +1 -0
- package/dist/cli/link.js +112 -0
- package/dist/cli/link.js.map +1 -0
- package/dist/cli/org.d.ts.map +1 -1
- package/dist/cli/org.js +79 -67
- package/dist/cli/org.js.map +1 -1
- package/dist/cli/platform.d.ts +3 -0
- package/dist/cli/platform.d.ts.map +1 -0
- package/dist/cli/platform.js +133 -0
- package/dist/cli/platform.js.map +1 -0
- package/dist/cli/tickets.d.ts +3 -0
- package/dist/cli/tickets.d.ts.map +1 -0
- package/dist/cli/tickets.js +125 -0
- package/dist/cli/tickets.js.map +1 -0
- package/dist/cli/utils/output.d.ts +15 -9
- package/dist/cli/utils/output.d.ts.map +1 -1
- package/dist/cli/utils/output.js +53 -46
- package/dist/cli/utils/output.js.map +1 -1
- package/dist/cli/voice.d.ts +3 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +206 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/core/diff.d.ts +16 -0
- package/dist/core/diff.d.ts.map +1 -0
- package/dist/core/diff.js +57 -0
- package/dist/core/diff.js.map +1 -0
- package/dist/core/org-resolver.d.ts +37 -0
- package/dist/core/org-resolver.d.ts.map +1 -0
- package/dist/core/org-resolver.js +73 -0
- package/dist/core/org-resolver.js.map +1 -0
- package/dist/core/runtime.d.ts +36 -0
- package/dist/core/runtime.d.ts.map +1 -0
- package/dist/core/runtime.js +62 -0
- package/dist/core/runtime.js.map +1 -0
- package/dist/core/scope.d.ts +33 -0
- package/dist/core/scope.d.ts.map +1 -0
- package/dist/core/scope.js +37 -0
- package/dist/core/scope.js.map +1 -0
- package/dist/mcp/registry.d.ts +67 -0
- package/dist/mcp/registry.d.ts.map +1 -0
- package/dist/mcp/registry.js +195 -0
- package/dist/mcp/registry.js.map +1 -0
- package/dist/mcp/server.d.ts +9 -0
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +87 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tool-meta.d.ts +21 -0
- package/dist/mcp/tool-meta.d.ts.map +1 -0
- package/dist/mcp/tool-meta.js +203 -0
- package/dist/mcp/tool-meta.js.map +1 -0
- package/dist/mcp/tools.d.ts +2 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +1462 -215
- package/dist/mcp/tools.js.map +1 -1
- package/dist/sdk/agent-bundle.d.ts +99 -0
- package/dist/sdk/agent-bundle.d.ts.map +1 -0
- package/dist/sdk/agent-bundle.js +211 -0
- package/dist/sdk/agent-bundle.js.map +1 -0
- package/dist/sdk/agents.d.ts +54 -1
- package/dist/sdk/agents.d.ts.map +1 -1
- package/dist/sdk/agents.js +59 -5
- package/dist/sdk/agents.js.map +1 -1
- package/dist/sdk/analytics.d.ts +158 -0
- package/dist/sdk/analytics.d.ts.map +1 -0
- package/dist/sdk/analytics.js +185 -0
- package/dist/sdk/analytics.js.map +1 -0
- package/dist/sdk/auth.d.ts +34 -12
- package/dist/sdk/auth.d.ts.map +1 -1
- package/dist/sdk/auth.js +62 -79
- package/dist/sdk/auth.js.map +1 -1
- package/dist/sdk/billing.d.ts +25 -0
- package/dist/sdk/billing.d.ts.map +1 -0
- package/dist/sdk/billing.js +61 -0
- package/dist/sdk/billing.js.map +1 -0
- package/dist/sdk/campaigns.d.ts +35 -0
- package/dist/sdk/campaigns.d.ts.map +1 -0
- package/dist/sdk/campaigns.js +59 -0
- package/dist/sdk/campaigns.js.map +1 -0
- package/dist/sdk/chat.d.ts +25 -2
- package/dist/sdk/chat.d.ts.map +1 -1
- package/dist/sdk/chat.js +37 -10
- package/dist/sdk/chat.js.map +1 -1
- package/dist/sdk/client.d.ts +93 -5
- package/dist/sdk/client.d.ts.map +1 -1
- package/dist/sdk/client.js +362 -100
- package/dist/sdk/client.js.map +1 -1
- package/dist/sdk/config-change.d.ts +74 -0
- package/dist/sdk/config-change.d.ts.map +1 -0
- package/dist/sdk/config-change.js +117 -0
- package/dist/sdk/config-change.js.map +1 -0
- package/dist/sdk/config.d.ts.map +1 -1
- package/dist/sdk/config.js +2 -1
- package/dist/sdk/config.js.map +1 -1
- package/dist/sdk/conversations.d.ts +101 -0
- package/dist/sdk/conversations.d.ts.map +1 -0
- package/dist/sdk/conversations.js +155 -0
- package/dist/sdk/conversations.js.map +1 -0
- package/dist/sdk/credentials.d.ts +25 -0
- package/dist/sdk/credentials.d.ts.map +1 -0
- package/dist/sdk/credentials.js +44 -0
- package/dist/sdk/credentials.js.map +1 -0
- package/dist/sdk/distribution.d.ts +94 -0
- package/dist/sdk/distribution.d.ts.map +1 -0
- package/dist/sdk/distribution.js +100 -0
- package/dist/sdk/distribution.js.map +1 -0
- package/dist/sdk/eval.d.ts +81 -0
- package/dist/sdk/eval.d.ts.map +1 -1
- package/dist/sdk/eval.js +117 -0
- package/dist/sdk/eval.js.map +1 -1
- package/dist/sdk/functions.d.ts +14 -1
- package/dist/sdk/functions.d.ts.map +1 -1
- package/dist/sdk/functions.js +48 -5
- package/dist/sdk/functions.js.map +1 -1
- package/dist/sdk/index.d.ts +2 -0
- package/dist/sdk/index.d.ts.map +1 -1
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/index.js.map +1 -1
- package/dist/sdk/jarvis-chat.d.ts +28 -0
- package/dist/sdk/jarvis-chat.d.ts.map +1 -0
- package/dist/sdk/jarvis-chat.js +117 -0
- package/dist/sdk/jarvis-chat.js.map +1 -0
- package/dist/sdk/kb.d.ts +69 -1
- package/dist/sdk/kb.d.ts.map +1 -1
- package/dist/sdk/kb.js +111 -24
- package/dist/sdk/kb.js.map +1 -1
- package/dist/sdk/orgs.d.ts +13 -0
- package/dist/sdk/orgs.d.ts.map +1 -0
- package/dist/sdk/orgs.js +59 -0
- package/dist/sdk/orgs.js.map +1 -0
- package/dist/sdk/platform.d.ts +62 -0
- package/dist/sdk/platform.d.ts.map +1 -0
- package/dist/sdk/platform.js +95 -0
- package/dist/sdk/platform.js.map +1 -0
- package/dist/sdk/session.d.ts +25 -0
- package/dist/sdk/session.d.ts.map +1 -0
- package/dist/sdk/session.js +69 -0
- package/dist/sdk/session.js.map +1 -0
- package/dist/sdk/skills.d.ts +46 -0
- package/dist/sdk/skills.d.ts.map +1 -0
- package/dist/sdk/skills.js +203 -0
- package/dist/sdk/skills.js.map +1 -0
- package/dist/sdk/test-chat.d.ts +52 -0
- package/dist/sdk/test-chat.d.ts.map +1 -0
- package/dist/sdk/test-chat.js +134 -0
- package/dist/sdk/test-chat.js.map +1 -0
- package/dist/sdk/tickets.d.ts +98 -0
- package/dist/sdk/tickets.d.ts.map +1 -0
- package/dist/sdk/tickets.js +91 -0
- package/dist/sdk/tickets.js.map +1 -0
- package/dist/sdk/types.d.ts +91 -6
- package/dist/sdk/types.d.ts.map +1 -1
- package/dist/sdk/voice.d.ts +147 -0
- package/dist/sdk/voice.d.ts.map +1 -0
- package/dist/sdk/voice.js +236 -0
- package/dist/sdk/voice.js.map +1 -0
- package/package.json +21 -4
- package/scripts/check-skills.mjs +25 -0
- package/skills/agent-builder/SKILL.md +89 -252
- package/skills/evaluations/SKILL.md +23 -234
- package/skills/evaluations/references/analysis.md +95 -0
- package/skills/evaluations/references/cli-reference.md +28 -0
- package/skills/evaluations/references/json-formats.md +100 -0
- package/src/cli/agents.ts +202 -22
- package/src/cli/analytics.ts +138 -0
- package/src/cli/auth.ts +56 -18
- package/src/cli/billing.ts +25 -0
- package/src/cli/campaigns.ts +33 -0
- package/src/cli/chat.ts +66 -115
- package/src/cli/config.ts +65 -31
- package/src/cli/conversations.ts +133 -0
- package/src/cli/distribution.ts +116 -0
- package/src/cli/eval.ts +254 -70
- package/src/cli/functions.ts +105 -39
- package/src/cli/index.ts +58 -55
- package/src/cli/init.ts +154 -0
- package/src/cli/kb.ts +120 -17
- package/src/cli/link.ts +117 -0
- package/src/cli/org.ts +81 -75
- package/src/cli/platform.ts +128 -0
- package/src/cli/tickets.ts +130 -0
- package/src/cli/utils/output.ts +51 -42
- package/src/cli/voice.ts +204 -0
- package/src/core/diff.ts +52 -0
- package/src/core/org-resolver.ts +113 -0
- package/src/core/runtime.ts +73 -0
- package/src/core/scope.ts +64 -0
- package/src/mcp/registry.ts +236 -0
- package/src/mcp/server.ts +84 -2
- package/src/mcp/tool-meta.ts +241 -0
- package/src/mcp/tools.ts +1529 -265
- package/src/sdk/agent-bundle.ts +245 -0
- package/src/sdk/agents.ts +74 -5
- package/src/sdk/analytics.ts +220 -0
- package/src/sdk/auth.ts +89 -109
- package/src/sdk/billing.ts +59 -0
- package/src/sdk/campaigns.ts +60 -0
- package/src/sdk/chat.ts +44 -10
- package/src/sdk/client.ts +373 -102
- package/src/sdk/config-change.ts +148 -0
- package/src/sdk/config.ts +2 -1
- package/src/sdk/conversations.ts +199 -0
- package/src/sdk/credentials.ts +53 -0
- package/src/sdk/distribution.ts +132 -0
- package/src/sdk/eval.ts +140 -0
- package/src/sdk/functions.ts +52 -6
- package/src/sdk/index.ts +2 -0
- package/src/sdk/jarvis-chat.ts +112 -0
- package/src/sdk/kb.ts +116 -29
- package/src/sdk/orgs.ts +62 -0
- package/src/sdk/platform.ts +115 -0
- package/src/sdk/session.ts +90 -0
- package/src/sdk/skills.ts +188 -0
- package/src/sdk/test-chat.ts +151 -0
- package/src/sdk/tickets.ts +120 -0
- package/src/sdk/types.ts +88 -6
- package/src/sdk/voice.ts +262 -0
- package/test/cli/entry.test.ts +34 -0
- package/test/cli/init.test.ts +66 -0
- package/test/cli/link.test.ts +57 -0
- package/test/core/org-resolver.test.ts +92 -0
- package/test/core/runtime.test.ts +30 -0
- package/test/core/scope.test.ts +93 -0
- package/test/harness.test.ts +24 -0
- package/test/helpers/config-home.ts +22 -0
- package/test/helpers/fake-middleware.ts +56 -0
- package/test/helpers/fetch-recorder.ts +75 -0
- package/test/helpers/jwt.ts +9 -0
- package/test/helpers/mcp-pair.ts +38 -0
- package/test/mcp/__snapshots__/tools-snapshot.test.ts.snap +145 -0
- package/test/mcp/askers.test.ts +60 -0
- package/test/mcp/elicit-timeout.test.ts +29 -0
- package/test/mcp/registry.test.ts +169 -0
- package/test/mcp/shaping.test.ts +41 -0
- package/test/mcp/tools-snapshot.test.ts +42 -0
- package/test/sdk/agents.test.ts +232 -0
- package/test/sdk/analytics.test.ts +89 -0
- package/test/sdk/auth.test.ts +70 -0
- package/test/sdk/billing.test.ts +47 -0
- package/test/sdk/campaigns.test.ts +43 -0
- package/test/sdk/client.test.ts +80 -0
- package/test/sdk/config-change.test.ts +142 -0
- package/test/sdk/conversations.test.ts +80 -0
- package/test/sdk/credentials.test.ts +33 -0
- package/test/sdk/distribution.test.ts +55 -0
- package/test/sdk/eval.test.ts +155 -0
- package/test/sdk/kb-upload.test.ts +50 -0
- package/test/sdk/kb.test.ts +95 -0
- package/test/sdk/orgs.test.ts +48 -0
- package/test/sdk/platform.test.ts +89 -0
- package/test/sdk/renewal.test.ts +115 -0
- package/test/sdk/session.test.ts +60 -0
- package/test/sdk/skills.test.ts +235 -0
- package/test/sdk/test-chat.test.ts +112 -0
- package/test/sdk/tickets.test.ts +42 -0
- package/test/sdk/voice.test.ts +147 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
5
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
6
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
7
|
+
import { login, logout, whoami } from '../../src/sdk/auth.js';
|
|
8
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
9
|
+
|
|
10
|
+
const A = { _id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'QA A' };
|
|
11
|
+
const B = { _id: 'bbbbbbbbbbbbbbbbbbbbbbbb', name: 'QA B' };
|
|
12
|
+
const fakeFirebase = vi.fn(async () => ({ idToken: 'id1', refreshToken: 'r1' }));
|
|
13
|
+
|
|
14
|
+
describe('token-first login', () => {
|
|
15
|
+
let home: ReturnType<typeof useTempHome>;
|
|
16
|
+
beforeEach(() => { cleanEnv(); home = useTempHome(); });
|
|
17
|
+
afterEach(() => home.cleanup());
|
|
18
|
+
|
|
19
|
+
it('keeps the session and the org list, and never creates an API key', async () => {
|
|
20
|
+
const token = jwtExpiringIn(24 * 3_600_000);
|
|
21
|
+
const { calls } = recordFetch([
|
|
22
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, token: `JWT ${token}`, user: { _id: 'u1', email: 'qa@indemn.ai' } } }),
|
|
23
|
+
on('GET', '/organization', { json: { success: true, userOrganizations: [A, B] } }),
|
|
24
|
+
]);
|
|
25
|
+
const result = await login('qa@indemn.ai', 'pw', 'dev', { firebaseSignIn: fakeFirebase });
|
|
26
|
+
|
|
27
|
+
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual(['POST /api/auth/firebase/signin', 'GET /api/organization']);
|
|
28
|
+
expect(calls.some((c) => c.url.includes('/auth/api-keys'))).toBe(false);
|
|
29
|
+
expect(calls[1].headers.authorization).toBe(`JWT ${token}`);
|
|
30
|
+
expect(result.orgs).toEqual([{ id: A._id, name: 'QA A' }, { id: B._id, name: 'QA B' }]);
|
|
31
|
+
|
|
32
|
+
const saved = JSON.parse(readFileSync(join(home.dir, '.indemn', 'config.json'), 'utf8'));
|
|
33
|
+
expect(saved.session).toMatchObject({ token, refresh_token: 'r1' });
|
|
34
|
+
expect(saved.orgs).toHaveLength(2);
|
|
35
|
+
expect(saved).not.toHaveProperty('api_key');
|
|
36
|
+
expect(saved).not.toHaveProperty('org_id');
|
|
37
|
+
expect(saved.environment).toBe('dev');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('asks for the MFA code when the account has MFA', async () => {
|
|
41
|
+
const ask = vi.fn(async () => '654321');
|
|
42
|
+
recordFetch([
|
|
43
|
+
on('POST', '/auth/firebase/signin', { json: { success: true, requiresMfa: true, mfaSessionId: 's1' } }),
|
|
44
|
+
on('POST', '/auth/firebase/mfa/verify', { json: { success: true, token: `JWT ${jwtExpiringIn(3_600_000 * 24)}`, user: { _id: 'u1' } } }),
|
|
45
|
+
on('GET', '/organization', { json: { success: true, userOrganizations: [A] } }),
|
|
46
|
+
]);
|
|
47
|
+
await login('qa@indemn.ai', 'pw', 'production', { firebaseSignIn: fakeFirebase, mfaCodePrompt: ask });
|
|
48
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(IndemnClient.loadConfig()?.environment).toBe('production');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('logout revokes the session on copilot, then forgets it', async () => {
|
|
53
|
+
const token = jwtExpiringIn(3_600_000 * 24);
|
|
54
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token } });
|
|
55
|
+
const { calls } = recordFetch([on('POST', '/auth/firebase/logout', { json: { success: true } })]);
|
|
56
|
+
await expect(logout()).resolves.toEqual({ revoked: true });
|
|
57
|
+
expect(calls[0].headers.authorization).toBe(`JWT ${token}`);
|
|
58
|
+
expect(IndemnClient.loadConfig()).toBeNull();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('whoami reports who and where but no secret', () => {
|
|
62
|
+
const token = jwtExpiringIn(3_600_000 * 24);
|
|
63
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', email: 'qa@indemn.ai', session: { token, refresh_token: 'r1', expires_at: '2026-09-29T12:00:00.000Z' }, orgs: [{ id: A._id, name: 'QA A' }] });
|
|
64
|
+
const info = whoami();
|
|
65
|
+
expect(info).toMatchObject({ environment: 'dev', email: 'qa@indemn.ai', credential: 'session', source: 'config', sessionExpiresAt: '2026-09-29T12:00:00.000Z' });
|
|
66
|
+
const text = JSON.stringify(info);
|
|
67
|
+
expect(text).not.toContain(token);
|
|
68
|
+
expect(text).not.toContain('r1');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
4
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { BillingSDK } from '../../src/sdk/billing.js';
|
|
8
|
+
|
|
9
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
10
|
+
|
|
11
|
+
describe('billing is read-only', () => {
|
|
12
|
+
let home: ReturnType<typeof useTempHome>;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
cleanEnv();
|
|
15
|
+
home = useTempHome();
|
|
16
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
17
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => home.cleanup());
|
|
20
|
+
|
|
21
|
+
it('has no write call in the billing module', () => {
|
|
22
|
+
const src = readFileSync(new URL('../../src/sdk/billing.ts', import.meta.url), 'utf8');
|
|
23
|
+
expect(src).not.toMatch(/copilot(Post|Put|Delete|RequestRaw|PostForm|Binary)|platformRequest|evalsPost/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('only ever sends GET requests, even across every call', async () => {
|
|
27
|
+
const { calls } = recordFetch([
|
|
28
|
+
on('GET', '/billing-subscription', { json: { success: true, _id: 'sub1', status: 'active' } }),
|
|
29
|
+
on('GET', '/subscription/sub1/unpaid', { json: { success: true, unpaidInvoices: [] } }),
|
|
30
|
+
on('GET', '/subscription/sub1', { json: { success: true, invoices: [{ amount: 100 }] } }),
|
|
31
|
+
on('GET', '/usage-forecast', { json: { success: true, meters: [] } }),
|
|
32
|
+
on('GET', '/customer-plans', { json: { success: true, plans: [] } }),
|
|
33
|
+
on('GET', '/subscription-plans', { json: { success: true, data: [] } }),
|
|
34
|
+
]);
|
|
35
|
+
const sdk = new BillingSDK(new IndemnClient());
|
|
36
|
+
await sdk.status(); await sdk.invoices(); await sdk.unpaid(); await sdk.usageForecast(); await sdk.plans();
|
|
37
|
+
expect(calls.length).toBeGreaterThan(5);
|
|
38
|
+
expect(calls.every((c) => c.method === 'GET')).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('treats "no subscription" (a 500 from copilot) as none', async () => {
|
|
42
|
+
recordFetch([on('GET', '/billing-subscription', { status: 500, text: 'boom' })]);
|
|
43
|
+
const sdk = new BillingSDK(new IndemnClient());
|
|
44
|
+
expect(await sdk.status()).toBeNull();
|
|
45
|
+
expect(await sdk.invoices()).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
4
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
|
+
import { jwtExpiringIn } from '../helpers/jwt.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { CampaignsSDK } from '../../src/sdk/campaigns.js';
|
|
8
|
+
|
|
9
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
10
|
+
|
|
11
|
+
describe('campaigns are read-only', () => {
|
|
12
|
+
let home: ReturnType<typeof useTempHome>;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
cleanEnv();
|
|
15
|
+
home = useTempHome();
|
|
16
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
17
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => home.cleanup());
|
|
20
|
+
|
|
21
|
+
it('has no write call in the campaigns module', () => {
|
|
22
|
+
const src = readFileSync(new URL('../../src/sdk/campaigns.ts', import.meta.url), 'utf8');
|
|
23
|
+
expect(src).not.toMatch(/copilot(Post|Put|Delete|RequestRaw|PostForm|Binary)/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('reads through the org proxy with GET only', async () => {
|
|
27
|
+
const { calls } = recordFetch([on('GET', '/campaigns-service/', { json: { total: 0, limit: 50, skip: 0, items: [] } })]);
|
|
28
|
+
const sdk = new CampaignsSDK(new IndemnClient());
|
|
29
|
+
await sdk.list(); await sdk.detail('c1', 'forecast', 30); await sdk.templates(); await sdk.template('t1', true);
|
|
30
|
+
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
|
|
31
|
+
`GET /api/organization/${ORG}/campaigns-service/campaigns`,
|
|
32
|
+
`GET /api/organization/${ORG}/campaigns-service/campaigns/c1/forecast`,
|
|
33
|
+
`GET /api/organization/${ORG}/campaigns-service/templates`,
|
|
34
|
+
`GET /api/organization/${ORG}/campaigns-service/templates/t1/versions`,
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('refuses a path outside the readable list', async () => {
|
|
39
|
+
const { calls } = recordFetch();
|
|
40
|
+
await expect(new CampaignsSDK(new IndemnClient()).get('c1/../../communications/x/send')).rejects.toThrow(/not readable/);
|
|
41
|
+
expect(calls).toHaveLength(0);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { on, recordFetch } from '../helpers/fetch-recorder.js';
|
|
4
|
+
import { cleanEnv, useTempHome } from '../helpers/config-home.js';
|
|
5
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
6
|
+
|
|
7
|
+
const version = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
|
|
8
|
+
const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
|
|
9
|
+
const LIVE_KEY = `ind_live_${'b'.repeat(64)}`;
|
|
10
|
+
|
|
11
|
+
describe('IndemnClient request path', () => {
|
|
12
|
+
let home: ReturnType<typeof useTempHome>;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
cleanEnv();
|
|
15
|
+
home = useTempHome();
|
|
16
|
+
vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void) => { fn(); return 0; }) as never);
|
|
17
|
+
});
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
IndemnClient.setClientKind('cli');
|
|
20
|
+
home.cleanup();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('names the CLI and its version on copilot and evaluations requests', async () => {
|
|
24
|
+
const { calls } = recordFetch();
|
|
25
|
+
const client = new IndemnClient({ apiKey: DEV_KEY, orgId: 'org1', environment: 'dev' });
|
|
26
|
+
await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
27
|
+
await client.evalsGet('/api/v1/rubrics');
|
|
28
|
+
IndemnClient.setClientKind('mcp');
|
|
29
|
+
await client.copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
30
|
+
expect(calls.map((c) => c.headers['user-agent'])).toEqual(Array(3).fill(`indemn-cli/${version}`));
|
|
31
|
+
expect(calls.map((c) => c.headers['x-indemn-client'])).toEqual(['cli', 'cli', 'mcp']);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('ignores a stray INDEMN_ENV: the saved login decides the platform', async () => {
|
|
35
|
+
IndemnClient.saveConfig({ api_key: DEV_KEY, org_id: 'org1', user_id: 'u1', environment: 'dev' });
|
|
36
|
+
vi.stubEnv('INDEMN_ENV', 'production');
|
|
37
|
+
const { calls } = recordFetch();
|
|
38
|
+
await new IndemnClient({ orgId: 'org1' }).copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
39
|
+
expect(calls[0].url).toBe('https://devcopilot.indemn.ai/api/organization/org1/ai-studio/v2/bots');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('takes the environment from the API key prefix when there is no saved login', () => {
|
|
43
|
+
vi.stubEnv('INDEMN_API_KEY', LIVE_KEY);
|
|
44
|
+
expect(new IndemnClient().getEnvironment()).toBe('production');
|
|
45
|
+
vi.stubEnv('INDEMN_API_KEY', DEV_KEY);
|
|
46
|
+
expect(new IndemnClient().getEnvironment()).toBe('dev');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps the server\'s reason when something is not found', async () => {
|
|
50
|
+
recordFetch([on('GET', '/voice-thing', { status: 404, json: { success: false, message: 'Phone number not found in channel configuration' } }), on('GET', '/html-404', { status: 404, text: '<html>nope</html>' })]);
|
|
51
|
+
const client = new IndemnClient({ apiKey: DEV_KEY, orgId: 'org1', environment: 'dev' });
|
|
52
|
+
await expect(client.copilotGet('/voice-thing')).rejects.toThrow('Not found: Phone number not found in channel configuration');
|
|
53
|
+
await expect(client.copilotGet('/html-404')).rejects.toThrow('Resource not found.');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('retries a read on 503 but never a write', async () => {
|
|
57
|
+
let gets = 0;
|
|
58
|
+
const { calls } = recordFetch([
|
|
59
|
+
on('GET', '/bots', () => (++gets === 1 ? { status: 503, text: 'busy' } : { json: { success: true, data: [{ _id: 'b1' }] } })),
|
|
60
|
+
on('POST', '/bots', { status: 503, text: 'busy' }),
|
|
61
|
+
]);
|
|
62
|
+
const client = new IndemnClient({ apiKey: DEV_KEY, orgId: 'org1', environment: 'dev' });
|
|
63
|
+
await expect(client.copilotGet('/organization/:org_id/ai-studio/v2/bots')).resolves.toEqual([{ _id: 'b1' }]);
|
|
64
|
+
await expect(client.copilotPost('/organization/:org_id/ai-studio/bots', { name: 'x' })).rejects.toThrow(/503/);
|
|
65
|
+
expect(calls.filter((c) => c.method === 'GET')).toHaveLength(2);
|
|
66
|
+
expect(calls.filter((c) => c.method === 'POST')).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('traces requests with --debug and never prints the credential', async () => {
|
|
70
|
+
vi.stubEnv('INDEMN_DEBUG', '1');
|
|
71
|
+
const writes: string[] = [];
|
|
72
|
+
vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string) => { writes.push(String(chunk)); return true; }) as never);
|
|
73
|
+
recordFetch();
|
|
74
|
+
await new IndemnClient({ apiKey: DEV_KEY, orgId: 'org1', environment: 'dev' }).copilotGet('/organization/:org_id/ai-studio/v2/bots');
|
|
75
|
+
const out = writes.join('');
|
|
76
|
+
expect(out).toContain('GET https://devcopilot.indemn.ai/api/organization/org1/ai-studio/v2/bots');
|
|
77
|
+
expect(out).toContain('200');
|
|
78
|
+
expect(out).not.toContain(DEV_KEY);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
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 { connectPair } from '../helpers/mcp-pair.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { formatDiff, lineDiff } from '../../src/core/diff.js';
|
|
8
|
+
import { applyConfigChange, modelArg, planConfigChange } from '../../src/sdk/config-change.js';
|
|
9
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
10
|
+
|
|
11
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
12
|
+
const AGENT = '111111111111111111111111';
|
|
13
|
+
|
|
14
|
+
const CATALOG = on('GET', '/ai-studio/llm-models', { json: { success: true, data: [{ provider: 'openai', models: [{ model_id: 'gpt-4.1', channels: ['CHAT', 'VOICE'], exposed_fields: { temperature: { default: 0 } } }, { model_id: 'o-text', channels: ['CHAT'] }] }] } });
|
|
15
|
+
|
|
16
|
+
function configServer(initial: { prompt: string; greeting?: string; model?: string }, opts: { lifecycle?: string; ignoreWrites?: boolean; voice?: boolean } = {}) {
|
|
17
|
+
let state = { ...initial };
|
|
18
|
+
const responders: Responder[] = [
|
|
19
|
+
on('GET', `/bots/${AGENT}/configurations`, () => ({ json: { success: true, data: {
|
|
20
|
+
ai_config: { system_prompt: state.prompt, system_prompt_ops: 'stale' },
|
|
21
|
+
first_message: { text: state.greeting ?? '', quick_replies: [{ title: 'Quote' }] },
|
|
22
|
+
llm_config: state.model ? { provider: state.model.split('/')[0], model: state.model.split('/')[1] } : undefined,
|
|
23
|
+
} } })),
|
|
24
|
+
on('PUT', `/bots/${AGENT}/configurations`, (c) => {
|
|
25
|
+
const b = c.body as Record<string, unknown>;
|
|
26
|
+
if (!opts.ignoreWrites) {
|
|
27
|
+
if (b.system_prompt !== undefined) state.prompt = String(b.system_prompt);
|
|
28
|
+
if (b.first_message) state.greeting = JSON.parse(String(b.first_message)).text;
|
|
29
|
+
if (b.llm_config) state.model = `${(b.llm_config as { provider: string }).provider}/${(b.llm_config as { model: string }).model}`;
|
|
30
|
+
}
|
|
31
|
+
return { json: { success: true, data: false } };
|
|
32
|
+
}),
|
|
33
|
+
on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT, name: 'Bot', channels: [{ type: opts.voice ? 'VOICE' : 'WEB' }] }] }),
|
|
34
|
+
CATALOG,
|
|
35
|
+
on('GET', `/v1-agents/${AGENT}`, opts.lifecycle ? { json: { id: AGENT, name: 'Bot', lifecycle: opts.lifecycle } } : { status: 503, text: 'down' }),
|
|
36
|
+
];
|
|
37
|
+
return { responders, state: () => state };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('model for a voice agent', () => {
|
|
41
|
+
let home: ReturnType<typeof useTempHome>;
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
cleanEnv();
|
|
44
|
+
home = useTempHome();
|
|
45
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
46
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: 'aaaaaaaaaaaaaaaaaaaaaaaa', name: 'QA A' }] });
|
|
47
|
+
});
|
|
48
|
+
afterEach(() => home.cleanup());
|
|
49
|
+
|
|
50
|
+
it('refuses a model that cannot take calls for an agent that answers the phone', async () => {
|
|
51
|
+
recordFetch(configServer({ prompt: 'p', model: 'openai/gpt-4.1' }, { voice: true }).responders);
|
|
52
|
+
await expect(planConfigChange(new IndemnClient(), AGENT, { model: modelArg('openai/o-text') })).rejects.toThrow(/cannot take calls/);
|
|
53
|
+
await expect(planConfigChange(new IndemnClient(), AGENT, { model: modelArg('openai/nope') })).rejects.toThrow(/not in the platform's model list/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('diff', () => {
|
|
58
|
+
it('shows removed and added lines with context', () => {
|
|
59
|
+
const d = lineDiff('a\nb\nc\nd', 'a\nB\nc\nd');
|
|
60
|
+
expect(formatDiff(d, { context: 1 })).toBe(' a\n- b\n+ B\n c');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('configuration changes', () => {
|
|
65
|
+
let home: ReturnType<typeof useTempHome>;
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
cleanEnv();
|
|
68
|
+
home = useTempHome();
|
|
69
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
70
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => home.cleanup());
|
|
73
|
+
|
|
74
|
+
it('plans only real changes and reads the lifecycle, or "unknown" when the V2 platform is down', async () => {
|
|
75
|
+
const srv = configServer({ prompt: 'one\ntwo', greeting: 'Hi', model: 'openai/gpt-4.1' });
|
|
76
|
+
recordFetch(srv.responders);
|
|
77
|
+
const plan = await planConfigChange(new IndemnClient(), AGENT, { system_prompt: 'one\nTWO', greeting: 'Hi', model: modelArg('openai/gpt-4.1') });
|
|
78
|
+
expect(plan.changes).toEqual(['system_prompt']);
|
|
79
|
+
expect(plan.lifecycle).toBe('unknown');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('clears the stale dashboard copy, sends the greeting as JSON text, and confirms by reading back', async () => {
|
|
83
|
+
const srv = configServer({ prompt: 'old', greeting: 'Hi' }, { lifecycle: 'beta' });
|
|
84
|
+
const { calls } = recordFetch(srv.responders);
|
|
85
|
+
const client = new IndemnClient();
|
|
86
|
+
const change = { system_prompt: 'new', greeting: 'Hello', model: modelArg('gpt-4.1') };
|
|
87
|
+
const plan = await planConfigChange(client, AGENT, change);
|
|
88
|
+
const result = await applyConfigChange(client, plan, change);
|
|
89
|
+
const put = calls.find((c) => c.method === 'PUT')!.body as Record<string, unknown>;
|
|
90
|
+
expect(put.system_prompt_ops).toBeNull();
|
|
91
|
+
expect(JSON.parse(String(put.first_message))).toEqual({ text: 'Hello', quick_replies: [{ title: 'Quote' }] });
|
|
92
|
+
expect(put.llm_config).toEqual({ provider: 'openai', model: 'gpt-4.1', parameters: { temperature: 0 } });
|
|
93
|
+
expect(result).toEqual({ applied: ['system_prompt', 'greeting', 'model'], mismatched: [] });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('reports what did not save even when copilot answers success', async () => {
|
|
97
|
+
const srv = configServer({ prompt: 'old' }, { lifecycle: 'beta', ignoreWrites: true });
|
|
98
|
+
recordFetch(srv.responders);
|
|
99
|
+
const client = new IndemnClient();
|
|
100
|
+
const plan = await planConfigChange(client, AGENT, { system_prompt: 'new' });
|
|
101
|
+
await expect(applyConfigChange(client, plan, { system_prompt: 'new' })).resolves.toEqual({ applied: [], mismatched: ['system_prompt'] });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('MCP set_prompt shows the diff before changing anything', async () => {
|
|
105
|
+
const srv = configServer({ prompt: 'keep\nold line' }, { lifecycle: 'beta' });
|
|
106
|
+
const { calls } = recordFetch(srv.responders);
|
|
107
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
108
|
+
const dry = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'keep\nnew line' });
|
|
109
|
+
expect(dry.body.dry_run).toBe(true);
|
|
110
|
+
expect(dry.body.would).toContain('- old line\n+ new line');
|
|
111
|
+
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
|
112
|
+
const done = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'keep\nnew line', confirm: true });
|
|
113
|
+
expect(done.body.data).toMatchObject({ changed: ['system_prompt'], not_saved: [] });
|
|
114
|
+
await pair.close();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('MCP needs confirm_published for a published agent when no person confirmed', async () => {
|
|
118
|
+
const srv = configServer({ prompt: 'old' }, { lifecycle: 'published' });
|
|
119
|
+
const { calls } = recordFetch(srv.responders);
|
|
120
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
121
|
+
const res = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true });
|
|
122
|
+
expect(res.body).toMatchObject({ needs_input: true });
|
|
123
|
+
expect(res.body.message).toMatch(/published/);
|
|
124
|
+
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
|
125
|
+
const ok = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true, confirm_published: true });
|
|
126
|
+
expect(ok.body.data.changed).toEqual(['system_prompt']);
|
|
127
|
+
await pair.close();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('MCP treats an agent whose status cannot be read as published', async () => {
|
|
131
|
+
const srv = configServer({ prompt: 'old' }); // the V2 platform is down: lifecycle "unknown"
|
|
132
|
+
const { calls } = recordFetch(srv.responders);
|
|
133
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
134
|
+
const res = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true });
|
|
135
|
+
expect(res.body).toMatchObject({ needs_input: true });
|
|
136
|
+
expect(res.body.message).toMatch(/may be live for customers \(its status could not be read\)/);
|
|
137
|
+
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
|
138
|
+
const ok = await pair.call('set_prompt', { agent_id: AGENT, prompt: 'new', confirm: true, confirm_published: true });
|
|
139
|
+
expect(ok.body.data.changed).toEqual(['system_prompt']);
|
|
140
|
+
await pair.close();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
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 { connectPair } from '../helpers/mcp-pair.js';
|
|
6
|
+
import { IndemnClient } from '../../src/sdk/client.js';
|
|
7
|
+
import { ConversationsSDK, conversationToTestSet } from '../../src/sdk/conversations.js';
|
|
8
|
+
import { registerTools } from '../../src/mcp/tools.js';
|
|
9
|
+
|
|
10
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
11
|
+
const AGENT = '111111111111111111111111';
|
|
12
|
+
const OTHER_AGENT = '333333333333333333333333';
|
|
13
|
+
const PROJ = '222222222222222222222222';
|
|
14
|
+
const agentOk = on('GET', `/ai-studio/v2/bots/${AGENT}`, { json: { success: true, data: [{ _id: AGENT, name: 'Quote Bot', id_project: PROJ }] } });
|
|
15
|
+
|
|
16
|
+
describe('conversations', () => {
|
|
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
|
+
|
|
26
|
+
it('lists one agent\'s conversations through the org API, test ones included, and can keep only CLI ones', async () => {
|
|
27
|
+
const { calls } = recordFetch([on('GET', `/organization/${ORG}/conversations`, { json: { perPage: 50, count: 2, conversations: [
|
|
28
|
+
{ request_id: 'r1', isTestMode: 'false', user_id: 'cli-user-1', created_at: '2026-09-28T10:00:00Z' },
|
|
29
|
+
{ request_id: 'r2', isTestMode: true, user_id: 'web-9' },
|
|
30
|
+
] } })]);
|
|
31
|
+
const sdk = new ConversationsSDK(new IndemnClient({ orgId: ORG }));
|
|
32
|
+
expect((await sdk.list(AGENT)).conversations.map((c) => c.request_id)).toEqual(['r1', 'r2']);
|
|
33
|
+
expect((await sdk.list(AGENT, { cliOnly: true, includeTest: false })).conversations.map((c) => c.request_id)).toEqual(['r1']);
|
|
34
|
+
const url = new URL(calls[0].url);
|
|
35
|
+
expect(url.pathname).toBe(`/api/organization/${ORG}/conversations`);
|
|
36
|
+
expect(url.searchParams.get('attr[id_bot]')).toBe(AGENT);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('labels who said each message', async () => {
|
|
40
|
+
recordFetch([on('GET', '/messages', { json: [
|
|
41
|
+
{ sender: 'cli-user-1', text: 'Get Started' }, { sender: `bot_${AGENT}`, text: 'Hi' }, { sender: 'system', text: 'Marked as test' },
|
|
42
|
+
{ sender: AGENT, text: 'Replies come under the bare agent id' }, { sender: 'cli-user-1', text: 'Thanks' },
|
|
43
|
+
] })]);
|
|
44
|
+
const msgs = await new ConversationsSDK(new IndemnClient()).messages(PROJ, 'r1', AGENT);
|
|
45
|
+
expect(msgs.map((m) => m.role)).toEqual(['user', 'agent', 'system', 'agent', 'user']);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('marks a conversation as test through the dashboard route', async () => {
|
|
49
|
+
const { calls } = recordFetch([on('PATCH', `/${PROJ}/requests/r1`, { json: { request_id: 'r1', isTestMode: true } })]);
|
|
50
|
+
await new ConversationsSDK(new IndemnClient()).markTest(PROJ, 'r1');
|
|
51
|
+
expect(calls[0]).toMatchObject({ method: 'PATCH', body: { isTestMode: true } });
|
|
52
|
+
expect(calls[0].headers.authorization).toMatch(/^JWT /);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('turns a conversation into a scenario test item without inventing criteria', () => {
|
|
56
|
+
const set = conversationToTestSet({ _id: 'x', request_id: 'r1', id_project: PROJ }, [
|
|
57
|
+
{ role: 'user', text: 'Get Started' }, { role: 'agent', text: 'Hi' }, { role: 'user', text: 'Quote for a van' },
|
|
58
|
+
{ role: 'agent', text: 'Here is your quote' }, { role: 'user', text: 'Thanks' },
|
|
59
|
+
], AGENT);
|
|
60
|
+
expect(set.items[0]).toMatchObject({
|
|
61
|
+
type: 'scenario',
|
|
62
|
+
inputs: { initial_message: 'Quote for a van', max_turns: 2 },
|
|
63
|
+
expected: { success_criteria: [], expected_outcome: 'Here is your quote' },
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('MCP mark_conversation_test returns a dry run until confirmed', async () => {
|
|
68
|
+
const { calls } = recordFetch([agentOk, on('PATCH', '/requests/', { json: { isTestMode: true } })]);
|
|
69
|
+
const pair = await connectPair((server) => { registerTools(server, new IndemnClient()); });
|
|
70
|
+
const dry = await pair.call('mark_conversation_test', { agent_id: AGENT, conversation_ids: ['r1', 'r2'] });
|
|
71
|
+
expect(dry.body).toMatchObject({ dry_run: true });
|
|
72
|
+
expect(dry.body.would).toContain('2 conversation(s)');
|
|
73
|
+
expect(dry.body.would).toContain('Org: QA A');
|
|
74
|
+
expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(0);
|
|
75
|
+
const done = await pair.call('mark_conversation_test', { agent_id: AGENT, conversation_ids: ['r1', 'r2'], confirm: true });
|
|
76
|
+
expect(done.body.data).toEqual({ marked_test: ['r1', 'r2'] });
|
|
77
|
+
expect(calls.filter((c) => c.method === 'PATCH')).toHaveLength(2);
|
|
78
|
+
await pair.close();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { copilotAuthHeader, darwinAuthHeader, resolveCredential } from '../../src/sdk/credentials.js';
|
|
3
|
+
import type { IndemnConfig } from '../../src/sdk/types.js';
|
|
4
|
+
|
|
5
|
+
const DEV_KEY = `ind_dev_${'a'.repeat(64)}`;
|
|
6
|
+
const config = (over: Partial<IndemnConfig> = {}) => ({ environment: 'dev', user_id: 'u1', hosts: {} as never, ...over }) as IndemnConfig;
|
|
7
|
+
|
|
8
|
+
describe('resolveCredential order', () => {
|
|
9
|
+
it('prefers a host-supplied token, which must name its platform', () => {
|
|
10
|
+
expect(resolveCredential(config({ session: { token: 'saved' } }), { INDEMN_TOKEN: 'JWT host', INDEMN_ENV: 'production' }))
|
|
11
|
+
.toEqual({ kind: 'token', token: 'host', source: 'env', environment: 'production' });
|
|
12
|
+
expect(() => resolveCredential(null, { INDEMN_TOKEN: 'host' })).toThrow(/INDEMN_ENV/);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('then a pipeline API key, then the saved session, then a legacy saved key', () => {
|
|
16
|
+
expect(resolveCredential(config({ session: { token: 'saved' } }), { INDEMN_API_KEY: DEV_KEY })).toMatchObject({ kind: 'api_key', source: 'env', environment: 'dev' });
|
|
17
|
+
expect(resolveCredential(config({ session: { token: 'saved' }, api_key: DEV_KEY }), {})).toMatchObject({ kind: 'token', token: 'saved', source: 'config' });
|
|
18
|
+
expect(resolveCredential(config({ api_key: DEV_KEY }), {})).toMatchObject({ kind: 'api_key', source: 'config' });
|
|
19
|
+
expect(resolveCredential(null, {})).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('auth headers', () => {
|
|
24
|
+
it('sends the session to copilot with the JWT scheme and a key as Bearer', () => {
|
|
25
|
+
expect(copilotAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('JWT t');
|
|
26
|
+
expect(copilotAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toBe(`Bearer ${DEV_KEY}`);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('sends the session to Darwin as Bearer and refuses an API key', () => {
|
|
30
|
+
expect(darwinAuthHeader({ kind: 'token', token: 't', source: 'config', environment: 'dev' })).toBe('Bearer t');
|
|
31
|
+
expect(() => darwinAuthHeader({ kind: 'api_key', key: DEV_KEY, source: 'env', environment: 'dev' })).toThrow(/login session/);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
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 { DistributionSDK } from '../../src/sdk/distribution.js';
|
|
7
|
+
|
|
8
|
+
const ORG = 'aaaaaaaaaaaaaaaaaaaaaaaa';
|
|
9
|
+
const OTHER_ORG = 'bbbbbbbbbbbbbbbbbbbbbbbb';
|
|
10
|
+
const AGENT = '111111111111111111111111';
|
|
11
|
+
|
|
12
|
+
describe('distributions and templates', () => {
|
|
13
|
+
let home: ReturnType<typeof useTempHome>;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
cleanEnv();
|
|
16
|
+
home = useTempHome();
|
|
17
|
+
vi.spyOn(process, 'cwd').mockReturnValue(home.dir);
|
|
18
|
+
IndemnClient.saveConfig({ environment: 'dev', user_id: 'u1', session: { token: jwtExpiringIn(5 * 3_600_000) }, orgs: [{ id: ORG, name: 'QA A' }] });
|
|
19
|
+
});
|
|
20
|
+
afterEach(() => home.cleanup());
|
|
21
|
+
const sdk = () => new DistributionSDK(new IndemnClient());
|
|
22
|
+
const list = on('GET', '/distributions', { json: { success: true, data: { paths: [{ id: 'd1', host_name: 'x.com', path_expression: '/q', id_bot: AGENT }], scriptTag: '<script src="s.js"></script>' } } });
|
|
23
|
+
|
|
24
|
+
it('never updates a distribution id that is not in this org (copilot would create it)', async () => {
|
|
25
|
+
const { calls } = recordFetch([list]);
|
|
26
|
+
await expect(sdk().update('d-unknown', { greetingMessage: 'hi' })).rejects.toThrow(/not in this org/);
|
|
27
|
+
expect(calls.some((c) => c.method === 'PUT')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('gives the embed code and the paths of one agent', async () => {
|
|
31
|
+
recordFetch([on('GET', `/v2/bots/${AGENT}`, { json: [{ _id: AGENT }] }), list]);
|
|
32
|
+
expect(await sdk().embed(AGENT)).toEqual({ scriptTag: '<script src="s.js"></script>', paths: [expect.objectContaining({ id: 'd1' })] });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('refuses to delete a template made from another org\'s agent', async () => {
|
|
36
|
+
const { calls } = recordFetch([on('GET', '/templates', { json: { success: true, data: [{ _id: 't1', name: 'Theirs', agent_id: { _id: 'x', id_organization: OTHER_ORG } }] } })]);
|
|
37
|
+
await expect(new DistributionSDK(new IndemnClient({ orgId: ORG })).deleteTemplate('t1')).rejects.toThrow(/another org/);
|
|
38
|
+
expect(calls.some((c) => c.method === 'DELETE')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('creates an agent from a template by cloning its source agent', async () => {
|
|
42
|
+
const { calls } = recordFetch([
|
|
43
|
+
on('GET', '/templates', { json: { success: true, data: [{ _id: 't1', name: 'Quote', agent_id: { _id: 'src1', id_organization: OTHER_ORG } }] } }),
|
|
44
|
+
on('POST', '/v2/bots/clone', { json: { success: true, data: { new_agent_id: 'n1', new_agent_name: 'Mine' } } }),
|
|
45
|
+
]);
|
|
46
|
+
const res = await new DistributionSDK(new IndemnClient({ orgId: ORG })).createFromTemplate('t1', { name: 'Mine' });
|
|
47
|
+
expect(res.new_agent_id).toBe('n1');
|
|
48
|
+
expect(calls.find((c) => c.method === 'POST')?.body).toEqual({ source_id_bot: 'src1', agent_name: 'Mine' });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reads solutions from their own key', async () => {
|
|
52
|
+
recordFetch([on('GET', '/solutions', { json: { success: true, solutions: [{ name: 'Renewals' }] } })]);
|
|
53
|
+
expect(await new DistributionSDK(new IndemnClient({ orgId: ORG })).listSolutions()).toEqual([{ name: 'Renewals' }]);
|
|
54
|
+
});
|
|
55
|
+
});
|