@framers/agentos-ext-topicality 0.1.0 → 0.2.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.
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @fileoverview Unit tests for the Topicality pack factory.
3
+ *
4
+ * Tests verify:
5
+ * - createTopicalityGuardrail returns an ExtensionPack with name 'topicality'
6
+ * and version '1.0.0'
7
+ * - The pack provides exactly 2 descriptors: 1 guardrail + 1 tool
8
+ * - Guardrail descriptor has id 'topicality-guardrail' and kind 'guardrail'
9
+ * - Tool descriptor has id 'check_topic' and kind 'tool'
10
+ * - createExtensionPack bridges context.options to createTopicalityGuardrail
11
+ * - onActivate rebuilds components with the shared registry
12
+ * - onDeactivate clears drift tracker sessions
13
+ */
14
+
15
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
16
+ import {
17
+ createTopicalityGuardrail,
18
+ createExtensionPack,
19
+ } from '../src/index';
20
+ import { SharedServiceRegistry } from '@framers/agentos';
21
+ import {
22
+ EXTENSION_KIND_GUARDRAIL,
23
+ EXTENSION_KIND_TOOL,
24
+ } from '@framers/agentos';
25
+ import type { ExtensionPackContext } from '@framers/agentos';
26
+ import type { TopicalityPackOptions, TopicDescriptor } from '../src/types';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Helpers
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** A simple allowed topic for testing. */
33
+ const BILLING_TOPIC: TopicDescriptor = {
34
+ id: 'billing',
35
+ name: 'Billing & Payments',
36
+ description: 'Questions about invoices and charges.',
37
+ examples: ['Why was I charged twice?'],
38
+ };
39
+
40
+ /** Mock embedding function to avoid real model calls. */
41
+ const mockEmbeddingFn = vi.fn(async (texts: string[]): Promise<number[][]> => {
42
+ return texts.map(() => [0.5, 0.5, 0.5]);
43
+ });
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Tests
47
+ // ---------------------------------------------------------------------------
48
+
49
+ describe('createTopicalityGuardrail', () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ // -------------------------------------------------------------------------
55
+ // 1. Pack identity
56
+ // -------------------------------------------------------------------------
57
+
58
+ describe('pack identity', () => {
59
+ it('returns an ExtensionPack with name "topicality"', () => {
60
+ const pack = createTopicalityGuardrail();
61
+
62
+ expect(pack.name).toBe('topicality');
63
+ });
64
+
65
+ it('returns an ExtensionPack with version "1.0.0"', () => {
66
+ const pack = createTopicalityGuardrail();
67
+
68
+ expect(pack.version).toBe('1.0.0');
69
+ });
70
+ });
71
+
72
+ // -------------------------------------------------------------------------
73
+ // 2. Descriptors shape
74
+ // -------------------------------------------------------------------------
75
+
76
+ describe('descriptors', () => {
77
+ it('provides exactly 2 descriptors', () => {
78
+ const pack = createTopicalityGuardrail();
79
+
80
+ expect(pack.descriptors).toHaveLength(2);
81
+ });
82
+
83
+ it('has a guardrail descriptor with id "topicality-guardrail"', () => {
84
+ const pack = createTopicalityGuardrail();
85
+ const guardrailDescriptor = pack.descriptors.find(
86
+ (d) => d.id === 'topicality-guardrail',
87
+ );
88
+
89
+ expect(guardrailDescriptor).toBeDefined();
90
+ });
91
+
92
+ it('guardrail descriptor has kind "guardrail"', () => {
93
+ const pack = createTopicalityGuardrail();
94
+ const guardrailDescriptor = pack.descriptors.find(
95
+ (d) => d.id === 'topicality-guardrail',
96
+ );
97
+
98
+ expect(guardrailDescriptor?.kind).toBe(EXTENSION_KIND_GUARDRAIL);
99
+ });
100
+
101
+ it('guardrail descriptor has priority 3', () => {
102
+ const pack = createTopicalityGuardrail();
103
+ const guardrailDescriptor = pack.descriptors.find(
104
+ (d) => d.id === 'topicality-guardrail',
105
+ );
106
+
107
+ expect(guardrailDescriptor?.priority).toBe(3);
108
+ });
109
+
110
+ it('guardrail descriptor has a non-null payload', () => {
111
+ const pack = createTopicalityGuardrail();
112
+ const guardrailDescriptor = pack.descriptors.find(
113
+ (d) => d.id === 'topicality-guardrail',
114
+ );
115
+
116
+ expect(guardrailDescriptor?.payload).toBeDefined();
117
+ expect(guardrailDescriptor?.payload).not.toBeNull();
118
+ });
119
+
120
+ it('has a tool descriptor with id "check_topic"', () => {
121
+ const pack = createTopicalityGuardrail();
122
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'check_topic');
123
+
124
+ expect(toolDescriptor).toBeDefined();
125
+ });
126
+
127
+ it('tool descriptor has kind "tool"', () => {
128
+ const pack = createTopicalityGuardrail();
129
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'check_topic');
130
+
131
+ expect(toolDescriptor?.kind).toBe(EXTENSION_KIND_TOOL);
132
+ });
133
+
134
+ it('tool descriptor has priority 0', () => {
135
+ const pack = createTopicalityGuardrail();
136
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'check_topic');
137
+
138
+ expect(toolDescriptor?.priority).toBe(0);
139
+ });
140
+
141
+ it('tool descriptor has a non-null payload', () => {
142
+ const pack = createTopicalityGuardrail();
143
+ const toolDescriptor = pack.descriptors.find((d) => d.id === 'check_topic');
144
+
145
+ expect(toolDescriptor?.payload).toBeDefined();
146
+ expect(toolDescriptor?.payload).not.toBeNull();
147
+ });
148
+ });
149
+
150
+ // -------------------------------------------------------------------------
151
+ // 3. Options passthrough
152
+ // -------------------------------------------------------------------------
153
+
154
+ describe('options passthrough', () => {
155
+ it('accepts allowed and forbidden topics without throwing', () => {
156
+ expect(() =>
157
+ createTopicalityGuardrail({
158
+ allowedTopics: [BILLING_TOPIC],
159
+ forbiddenTopics: [],
160
+ embeddingFn: mockEmbeddingFn,
161
+ }),
162
+ ).not.toThrow();
163
+ });
164
+
165
+ it('accepts custom thresholds and drift config', () => {
166
+ expect(() =>
167
+ createTopicalityGuardrail({
168
+ allowedThreshold: 0.5,
169
+ forbiddenThreshold: 0.8,
170
+ enableDriftDetection: true,
171
+ drift: { alpha: 0.5, driftStreakLimit: 5 },
172
+ embeddingFn: mockEmbeddingFn,
173
+ }),
174
+ ).not.toThrow();
175
+ });
176
+
177
+ it('accepts guardrailScope option', () => {
178
+ expect(() =>
179
+ createTopicalityGuardrail({
180
+ guardrailScope: 'both',
181
+ embeddingFn: mockEmbeddingFn,
182
+ }),
183
+ ).not.toThrow();
184
+ });
185
+ });
186
+
187
+ // -------------------------------------------------------------------------
188
+ // 4. onActivate lifecycle hook
189
+ // -------------------------------------------------------------------------
190
+
191
+ describe('onActivate lifecycle hook', () => {
192
+ it('does not throw when called with a shared registry', () => {
193
+ const pack = createTopicalityGuardrail({ embeddingFn: mockEmbeddingFn });
194
+ const sharedRegistry = new SharedServiceRegistry();
195
+
196
+ expect(() => pack.onActivate!({ services: sharedRegistry })).not.toThrow();
197
+ });
198
+
199
+ it('descriptors still reflect rebuilt components after onActivate', () => {
200
+ const pack = createTopicalityGuardrail({ embeddingFn: mockEmbeddingFn });
201
+ const sharedRegistry = new SharedServiceRegistry();
202
+ pack.onActivate!({ services: sharedRegistry });
203
+
204
+ // Descriptors getter must return fresh references.
205
+ expect(pack.descriptors).toHaveLength(2);
206
+ });
207
+
208
+ it('does not throw when onActivate is called without services', () => {
209
+ const pack = createTopicalityGuardrail({ embeddingFn: mockEmbeddingFn });
210
+
211
+ // Context without a services field should be handled gracefully.
212
+ expect(() => pack.onActivate!({})).not.toThrow();
213
+ });
214
+ });
215
+
216
+ // -------------------------------------------------------------------------
217
+ // 5. onDeactivate lifecycle hook
218
+ // -------------------------------------------------------------------------
219
+
220
+ describe('onDeactivate lifecycle hook', () => {
221
+ it('resolves without throwing', async () => {
222
+ const pack = createTopicalityGuardrail({ embeddingFn: mockEmbeddingFn });
223
+
224
+ await expect(pack.onDeactivate!()).resolves.toBeUndefined();
225
+ });
226
+
227
+ it('clears the guardrail drift tracker sessions on deactivate', async () => {
228
+ const pack = createTopicalityGuardrail({
229
+ allowedTopics: [BILLING_TOPIC],
230
+ enableDriftDetection: true,
231
+ embeddingFn: mockEmbeddingFn,
232
+ });
233
+
234
+ const guardrail = pack.descriptors.find((d) => d.id === 'topicality-guardrail')!
235
+ .payload as any;
236
+
237
+ await guardrail.evaluateInput({
238
+ context: { userId: 'u1', sessionId: 's1' },
239
+ input: { textInput: 'Need help with my invoice' },
240
+ });
241
+
242
+ expect(guardrail.driftTracker).toBeTruthy();
243
+ expect(guardrail.driftTracker.sessions.size).toBe(1);
244
+
245
+ await expect(pack.onDeactivate!()).resolves.toBeUndefined();
246
+ expect(guardrail.driftTracker.sessions.size).toBe(0);
247
+ });
248
+
249
+ it('handles deactivation when drift detection is disabled', async () => {
250
+ const pack = createTopicalityGuardrail({
251
+ enableDriftDetection: false,
252
+ embeddingFn: mockEmbeddingFn,
253
+ });
254
+
255
+ // Should not throw even when no drift tracker exists.
256
+ await expect(pack.onDeactivate!()).resolves.toBeUndefined();
257
+ });
258
+ });
259
+ });
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // createExtensionPack (manifest factory bridge)
263
+ // ---------------------------------------------------------------------------
264
+
265
+ describe('createExtensionPack', () => {
266
+ beforeEach(() => {
267
+ vi.clearAllMocks();
268
+ });
269
+
270
+ it('returns a pack with name "topicality"', () => {
271
+ const context: ExtensionPackContext = {};
272
+ const pack = createExtensionPack(context);
273
+
274
+ expect(pack.name).toBe('topicality');
275
+ });
276
+
277
+ it('returns a pack with version "1.0.0"', () => {
278
+ const context: ExtensionPackContext = {};
279
+ const pack = createExtensionPack(context);
280
+
281
+ expect(pack.version).toBe('1.0.0');
282
+ });
283
+
284
+ it('provides 2 descriptors with empty context', () => {
285
+ const pack = createExtensionPack({});
286
+
287
+ expect(pack.descriptors).toHaveLength(2);
288
+ });
289
+
290
+ it('bridges context.options to createTopicalityGuardrail', () => {
291
+ const context: ExtensionPackContext = {
292
+ options: {
293
+ allowedTopics: [BILLING_TOPIC],
294
+ embeddingFn: mockEmbeddingFn,
295
+ } as TopicalityPackOptions,
296
+ };
297
+
298
+ const pack = createExtensionPack(context);
299
+
300
+ // Pack must be well-formed.
301
+ expect(pack.descriptors).toHaveLength(2);
302
+ expect(pack.name).toBe('topicality');
303
+ });
304
+
305
+ it('works when context.options is undefined', () => {
306
+ const context: ExtensionPackContext = { options: undefined };
307
+ const pack = createExtensionPack(context);
308
+
309
+ expect(pack.name).toBe('topicality');
310
+ expect(pack.descriptors).toHaveLength(2);
311
+ });
312
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "declaration": true,
7
+ "declarationMap": true,
8
+ "sourceMap": true,
9
+ "outDir": "./dist",
10
+ "rootDir": "./src",
11
+ "strict": true,
12
+ "esModuleInterop": true,
13
+ "skipLibCheck": true,
14
+ "forceConsistentCasingInFileNames": true
15
+ },
16
+ "include": ["src"],
17
+ "exclude": ["dist", "node_modules"]
18
+ }
@@ -0,0 +1,24 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+
5
+ // CI layout: agentos cloned into packages/agentos/ inside this repo
6
+ const ciPath = path.resolve(__dirname, '../../../../packages/agentos/src');
7
+ // Monorepo layout: agentos is a sibling at packages/agentos/
8
+ const monoPath = path.resolve(__dirname, '../../../../../agentos/src');
9
+
10
+ const agentosPath = fs.existsSync(ciPath) ? ciPath : fs.existsSync(monoPath) ? monoPath : null;
11
+
12
+ export default defineConfig({
13
+ test: {
14
+ globals: true,
15
+ environment: 'node',
16
+ include: ['test/**/*.spec.ts'],
17
+ testTimeout: 10000,
18
+ },
19
+ resolve: agentosPath ? {
20
+ alias: {
21
+ '@framers/agentos': agentosPath,
22
+ },
23
+ } : {},
24
+ });