@aura-labs-ai/scout 0.2.0
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/README.md +237 -0
- package/bin/scout-cli.js +325 -0
- package/examples/basic-purchase.js +112 -0
- package/examples/protocol-integration.js +294 -0
- package/package.json +63 -0
- package/src/activity.js +420 -0
- package/src/ap2/mandates.js +366 -0
- package/src/client.js +217 -0
- package/src/errors.js +66 -0
- package/src/index.js +655 -0
- package/src/intent-session.js +233 -0
- package/src/key-manager.js +204 -0
- package/src/key-manager.test.js +293 -0
- package/src/mcp/client.js +458 -0
- package/src/session.js +603 -0
- package/src/tap/visa.js +345 -0
- package/src/tests/README.md +157 -0
- package/src/tests/ap2-mandates.test.js +413 -0
- package/src/tests/intent-session.test.js +292 -0
- package/src/tests/mcp-client.test.js +224 -0
- package/src/tests/ping.test.js +282 -0
- package/src/tests/run-protocol-tests.js +57 -0
- package/src/tests/scenarios/ap2-scenarios.test.js +422 -0
- package/src/tests/scenarios/index.js +60 -0
- package/src/tests/scenarios/integration-scenarios.test.js +514 -0
- package/src/tests/scenarios/mcp-scenarios.test.js +419 -0
- package/src/tests/scenarios/tap-scenarios.test.js +461 -0
- package/src/tests/visa-tap.test.js +376 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AP2 Mandates Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for Google Agent Payments Protocol (AP2) mandate creation and validation.
|
|
5
|
+
*
|
|
6
|
+
* Run:
|
|
7
|
+
* node --test src/tests/ap2-mandates.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, describe } from 'node:test';
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import { AP2Mandates, generateKeyPair } from '../ap2/mandates.js';
|
|
13
|
+
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// Test Fixtures
|
|
16
|
+
// =============================================================================
|
|
17
|
+
|
|
18
|
+
const testKeys = generateKeyPair();
|
|
19
|
+
|
|
20
|
+
const createTestIntent = (overrides = {}) => ({
|
|
21
|
+
agentId: 'test-agent-001',
|
|
22
|
+
userId: 'test-user-001',
|
|
23
|
+
userKey: testKeys.privateKey,
|
|
24
|
+
constraints: {
|
|
25
|
+
maxAmount: 5000,
|
|
26
|
+
currency: 'USD',
|
|
27
|
+
categories: ['electronics'],
|
|
28
|
+
validUntil: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
29
|
+
},
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const createTestOffer = (overrides = {}) => ({
|
|
34
|
+
id: 'offer-test-001',
|
|
35
|
+
beaconId: 'beacon-test-001',
|
|
36
|
+
beaconName: 'Test Merchant',
|
|
37
|
+
product: { name: 'Test Product', sku: 'TEST-001' },
|
|
38
|
+
unitPrice: 100,
|
|
39
|
+
quantity: 2,
|
|
40
|
+
totalPrice: 200,
|
|
41
|
+
currency: 'USD',
|
|
42
|
+
deliveryDate: '2026-03-01',
|
|
43
|
+
...overrides,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Intent Mandate Tests
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
describe('AP2 Intent Mandates', () => {
|
|
51
|
+
test('creates valid intent mandate with required fields', async () => {
|
|
52
|
+
const params = createTestIntent();
|
|
53
|
+
const mandate = await AP2Mandates.createIntent(params);
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(mandate.type, 'intent');
|
|
56
|
+
assert.strictEqual(mandate.version, '1.0');
|
|
57
|
+
assert.ok(mandate.id.startsWith('mandate_'));
|
|
58
|
+
assert.strictEqual(mandate.subject.id, params.agentId);
|
|
59
|
+
assert.strictEqual(mandate.issuer.id, params.userId);
|
|
60
|
+
assert.strictEqual(mandate.constraints.maxAmount, 5000);
|
|
61
|
+
assert.strictEqual(mandate.constraints.currency, 'USD');
|
|
62
|
+
assert.deepStrictEqual(mandate.constraints.categories, ['electronics']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('includes valid proof/signature', async () => {
|
|
66
|
+
const mandate = await AP2Mandates.createIntent(createTestIntent());
|
|
67
|
+
|
|
68
|
+
assert.ok(mandate.proof);
|
|
69
|
+
assert.strictEqual(mandate.proof.type, 'Ed25519Signature2020');
|
|
70
|
+
assert.ok(mandate.proof.created);
|
|
71
|
+
assert.ok(mandate.proof.verificationMethod);
|
|
72
|
+
assert.strictEqual(mandate.proof.proofPurpose, 'assertionMethod');
|
|
73
|
+
assert.ok(mandate.proof.proofValue);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('sets default values for optional fields', async () => {
|
|
77
|
+
const mandate = await AP2Mandates.createIntent(createTestIntent());
|
|
78
|
+
|
|
79
|
+
assert.strictEqual(mandate.constraints.requireUserPresent, false);
|
|
80
|
+
assert.strictEqual(mandate.constraints.merchantAllowlist, null);
|
|
81
|
+
assert.strictEqual(mandate.constraints.merchantBlocklist, null);
|
|
82
|
+
assert.strictEqual(mandate.constraints.maxTransactions, null);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('preserves custom constraint values', async () => {
|
|
86
|
+
const mandate = await AP2Mandates.createIntent(createTestIntent({
|
|
87
|
+
constraints: {
|
|
88
|
+
maxAmount: 1000,
|
|
89
|
+
currency: 'EUR',
|
|
90
|
+
requireUserPresent: true,
|
|
91
|
+
maxTransactions: 5,
|
|
92
|
+
merchantAllowlist: ['merchant-1', 'merchant-2'],
|
|
93
|
+
merchantBlocklist: ['bad-merchant'],
|
|
94
|
+
},
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
assert.strictEqual(mandate.constraints.maxAmount, 1000);
|
|
98
|
+
assert.strictEqual(mandate.constraints.currency, 'EUR');
|
|
99
|
+
assert.strictEqual(mandate.constraints.requireUserPresent, true);
|
|
100
|
+
assert.strictEqual(mandate.constraints.maxTransactions, 5);
|
|
101
|
+
assert.deepStrictEqual(mandate.constraints.merchantAllowlist, ['merchant-1', 'merchant-2']);
|
|
102
|
+
assert.deepStrictEqual(mandate.constraints.merchantBlocklist, ['bad-merchant']);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('includes metadata when provided', async () => {
|
|
106
|
+
const mandate = await AP2Mandates.createIntent(createTestIntent({
|
|
107
|
+
metadata: {
|
|
108
|
+
purpose: 'Office supplies',
|
|
109
|
+
department: 'Engineering',
|
|
110
|
+
},
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
assert.deepStrictEqual(mandate.metadata, {
|
|
114
|
+
purpose: 'Office supplies',
|
|
115
|
+
department: 'Engineering',
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// =============================================================================
|
|
121
|
+
// Cart Mandate Tests
|
|
122
|
+
// =============================================================================
|
|
123
|
+
|
|
124
|
+
describe('AP2 Cart Mandates', () => {
|
|
125
|
+
test('creates valid cart mandate from offer', async () => {
|
|
126
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent());
|
|
127
|
+
const offer = createTestOffer();
|
|
128
|
+
|
|
129
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
130
|
+
sessionId: 'session-test-001',
|
|
131
|
+
offer,
|
|
132
|
+
userKey: testKeys.privateKey,
|
|
133
|
+
userId: 'test-user-001',
|
|
134
|
+
intentMandateId: intentMandate.id,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(cartMandate.type, 'cart');
|
|
138
|
+
assert.strictEqual(cartMandate.version, '1.0');
|
|
139
|
+
assert.ok(cartMandate.id.startsWith('mandate_'));
|
|
140
|
+
assert.strictEqual(cartMandate.intentMandateRef, intentMandate.id);
|
|
141
|
+
assert.strictEqual(cartMandate.cart.sessionId, 'session-test-001');
|
|
142
|
+
assert.strictEqual(cartMandate.cart.offerId, offer.id);
|
|
143
|
+
assert.strictEqual(cartMandate.cart.totalAmount, 200);
|
|
144
|
+
assert.strictEqual(cartMandate.userPresent, true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('cart mandate includes offer details', async () => {
|
|
148
|
+
const offer = createTestOffer({
|
|
149
|
+
unitPrice: 150,
|
|
150
|
+
quantity: 3,
|
|
151
|
+
totalPrice: 450,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
155
|
+
sessionId: 'session-test-002',
|
|
156
|
+
offer,
|
|
157
|
+
userKey: testKeys.privateKey,
|
|
158
|
+
userId: 'test-user-001',
|
|
159
|
+
intentMandateId: 'mandate_test_123',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(cartMandate.cart.items[0].quantity, 3);
|
|
163
|
+
assert.strictEqual(cartMandate.cart.items[0].unitPrice, 150);
|
|
164
|
+
assert.strictEqual(cartMandate.cart.totalAmount, 450);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('cart mandate has expiration time', async () => {
|
|
168
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
169
|
+
sessionId: 'session-test-003',
|
|
170
|
+
offer: createTestOffer(),
|
|
171
|
+
userKey: testKeys.privateKey,
|
|
172
|
+
userId: 'test-user-001',
|
|
173
|
+
intentMandateId: 'mandate_test_123',
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const expiresAt = new Date(cartMandate.expiresAt).getTime();
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
|
|
179
|
+
// Should expire in ~30 minutes (give or take a few seconds)
|
|
180
|
+
const diffMinutes = (expiresAt - now) / (1000 * 60);
|
|
181
|
+
assert.ok(diffMinutes >= 29 && diffMinutes <= 31, `Expected ~30 min expiry, got ${diffMinutes} min`);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// =============================================================================
|
|
186
|
+
// Payment Mandate Tests
|
|
187
|
+
// =============================================================================
|
|
188
|
+
|
|
189
|
+
describe('AP2 Payment Mandates', () => {
|
|
190
|
+
test('creates payment mandate from cart mandate', async () => {
|
|
191
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
192
|
+
sessionId: 'session-test-004',
|
|
193
|
+
offer: createTestOffer({ totalPrice: 500 }),
|
|
194
|
+
userKey: testKeys.privateKey,
|
|
195
|
+
userId: 'test-user-001',
|
|
196
|
+
intentMandateId: 'mandate_test_123',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const paymentMandate = await AP2Mandates.createPayment({
|
|
200
|
+
cartMandate,
|
|
201
|
+
paymentMethod: { type: 'card', network: 'visa' },
|
|
202
|
+
agentId: 'agent-test-001',
|
|
203
|
+
agentKey: testKeys.privateKey,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
assert.strictEqual(paymentMandate.type, 'payment');
|
|
207
|
+
assert.strictEqual(paymentMandate.cartMandateRef, cartMandate.id);
|
|
208
|
+
assert.strictEqual(paymentMandate.transaction.amount, 500);
|
|
209
|
+
assert.strictEqual(paymentMandate.paymentMethod.type, 'card');
|
|
210
|
+
assert.strictEqual(paymentMandate.paymentMethod.network, 'visa');
|
|
211
|
+
assert.strictEqual(paymentMandate.paymentMethod.tokenized, true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('includes TAP credentials when provided', async () => {
|
|
215
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
216
|
+
sessionId: 'session-test-005',
|
|
217
|
+
offer: createTestOffer(),
|
|
218
|
+
userKey: testKeys.privateKey,
|
|
219
|
+
userId: 'test-user-001',
|
|
220
|
+
intentMandateId: 'mandate_test_123',
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const paymentMandate = await AP2Mandates.createPayment({
|
|
224
|
+
cartMandate,
|
|
225
|
+
paymentMethod: { type: 'card', network: 'mastercard' },
|
|
226
|
+
agentId: 'agent-test-001',
|
|
227
|
+
agentKey: testKeys.privateKey,
|
|
228
|
+
tapCredentials: { tapId: 'tap_agent_12345' },
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
assert.strictEqual(paymentMandate.agent.tapId, 'tap_agent_12345');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('includes risk signals', async () => {
|
|
235
|
+
const cartMandate = await AP2Mandates.createCart({
|
|
236
|
+
sessionId: 'session-test-006',
|
|
237
|
+
offer: createTestOffer(),
|
|
238
|
+
userKey: testKeys.privateKey,
|
|
239
|
+
userId: 'test-user-001',
|
|
240
|
+
intentMandateId: 'mandate_test_123',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const paymentMandate = await AP2Mandates.createPayment({
|
|
244
|
+
cartMandate,
|
|
245
|
+
paymentMethod: { type: 'card', network: 'visa' },
|
|
246
|
+
agentId: 'agent-test-001',
|
|
247
|
+
agentKey: testKeys.privateKey,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
assert.ok(paymentMandate.riskSignals);
|
|
251
|
+
assert.strictEqual(paymentMandate.riskSignals.userAuthTime, cartMandate.issuedAt);
|
|
252
|
+
assert.strictEqual(paymentMandate.riskSignals.intentMandatePresent, true);
|
|
253
|
+
assert.ok(typeof paymentMandate.riskSignals.agentSessionDuration === 'number');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// Intent Coverage Validation Tests
|
|
259
|
+
// =============================================================================
|
|
260
|
+
|
|
261
|
+
describe('AP2 Intent Coverage Validation', () => {
|
|
262
|
+
test('validates amount within limits', async () => {
|
|
263
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
264
|
+
constraints: { maxAmount: 1000 },
|
|
265
|
+
}));
|
|
266
|
+
|
|
267
|
+
const result = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
268
|
+
totalAmount: 500,
|
|
269
|
+
currency: 'USD',
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
assert.strictEqual(result.valid, true);
|
|
273
|
+
assert.deepStrictEqual(result.errors, []);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('rejects amount exceeding limit', async () => {
|
|
277
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
278
|
+
constraints: { maxAmount: 1000 },
|
|
279
|
+
}));
|
|
280
|
+
|
|
281
|
+
const result = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
282
|
+
totalAmount: 1500,
|
|
283
|
+
currency: 'USD',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
assert.strictEqual(result.valid, false);
|
|
287
|
+
assert.ok(result.errors[0].includes('exceeds max'));
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test('validates currency match', async () => {
|
|
291
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
292
|
+
constraints: { maxAmount: 1000, currency: 'EUR' },
|
|
293
|
+
}));
|
|
294
|
+
|
|
295
|
+
const result = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
296
|
+
totalAmount: 500,
|
|
297
|
+
currency: 'USD',
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
assert.strictEqual(result.valid, false);
|
|
301
|
+
assert.ok(result.errors[0].includes('Currency'));
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('validates category in allowlist', async () => {
|
|
305
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
306
|
+
constraints: {
|
|
307
|
+
maxAmount: 1000,
|
|
308
|
+
categories: ['electronics', 'office'],
|
|
309
|
+
},
|
|
310
|
+
}));
|
|
311
|
+
|
|
312
|
+
const validResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
313
|
+
totalAmount: 500,
|
|
314
|
+
currency: 'USD',
|
|
315
|
+
category: 'electronics',
|
|
316
|
+
});
|
|
317
|
+
assert.strictEqual(validResult.valid, true);
|
|
318
|
+
|
|
319
|
+
const invalidResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
320
|
+
totalAmount: 500,
|
|
321
|
+
currency: 'USD',
|
|
322
|
+
category: 'furniture',
|
|
323
|
+
});
|
|
324
|
+
assert.strictEqual(invalidResult.valid, false);
|
|
325
|
+
assert.ok(invalidResult.errors[0].includes('Category'));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('validates merchant allowlist', async () => {
|
|
329
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
330
|
+
constraints: {
|
|
331
|
+
maxAmount: 1000,
|
|
332
|
+
merchantAllowlist: ['merchant-a', 'merchant-b'],
|
|
333
|
+
},
|
|
334
|
+
}));
|
|
335
|
+
|
|
336
|
+
const validResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
337
|
+
totalAmount: 500,
|
|
338
|
+
merchantId: 'merchant-a',
|
|
339
|
+
});
|
|
340
|
+
assert.strictEqual(validResult.valid, true);
|
|
341
|
+
|
|
342
|
+
const invalidResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
343
|
+
totalAmount: 500,
|
|
344
|
+
merchantId: 'merchant-c',
|
|
345
|
+
});
|
|
346
|
+
assert.strictEqual(invalidResult.valid, false);
|
|
347
|
+
assert.ok(invalidResult.errors[0].includes('allowlist'));
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('validates merchant blocklist', async () => {
|
|
351
|
+
const intentMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
352
|
+
constraints: {
|
|
353
|
+
maxAmount: 1000,
|
|
354
|
+
merchantBlocklist: ['bad-merchant'],
|
|
355
|
+
},
|
|
356
|
+
}));
|
|
357
|
+
|
|
358
|
+
const validResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
359
|
+
totalAmount: 500,
|
|
360
|
+
merchantId: 'good-merchant',
|
|
361
|
+
});
|
|
362
|
+
assert.strictEqual(validResult.valid, true);
|
|
363
|
+
|
|
364
|
+
const invalidResult = AP2Mandates.validateIntentCoverage(intentMandate, {
|
|
365
|
+
totalAmount: 500,
|
|
366
|
+
merchantId: 'bad-merchant',
|
|
367
|
+
});
|
|
368
|
+
assert.strictEqual(invalidResult.valid, false);
|
|
369
|
+
assert.ok(invalidResult.errors[0].includes('blocklisted'));
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('validates expiration', async () => {
|
|
373
|
+
const expiredMandate = await AP2Mandates.createIntent(createTestIntent({
|
|
374
|
+
constraints: {
|
|
375
|
+
maxAmount: 1000,
|
|
376
|
+
validUntil: new Date(Date.now() - 1000).toISOString(), // Expired
|
|
377
|
+
},
|
|
378
|
+
}));
|
|
379
|
+
|
|
380
|
+
const result = AP2Mandates.validateIntentCoverage(expiredMandate, {
|
|
381
|
+
totalAmount: 500,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
assert.strictEqual(result.valid, false);
|
|
385
|
+
assert.ok(result.errors[0].includes('expired'));
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// =============================================================================
|
|
390
|
+
// Signature Verification Tests
|
|
391
|
+
// =============================================================================
|
|
392
|
+
|
|
393
|
+
describe('AP2 Signature Verification', () => {
|
|
394
|
+
test('verifies valid mandate signature', async () => {
|
|
395
|
+
const mandate = await AP2Mandates.createIntent(createTestIntent());
|
|
396
|
+
const result = await AP2Mandates.verify(mandate, testKeys.publicKey);
|
|
397
|
+
|
|
398
|
+
// Note: Using mock signatures in dev mode
|
|
399
|
+
assert.ok(result.valid || result.error === undefined);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('rejects mandate without proof', async () => {
|
|
403
|
+
const mandateWithoutProof = {
|
|
404
|
+
type: 'intent',
|
|
405
|
+
id: 'test-mandate',
|
|
406
|
+
// No proof field
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const result = await AP2Mandates.verify(mandateWithoutProof, testKeys.publicKey);
|
|
410
|
+
assert.strictEqual(result.valid, false);
|
|
411
|
+
assert.ok(result.error.includes('No proof'));
|
|
412
|
+
});
|
|
413
|
+
});
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IntentSession Tests — Phase B F22–F34
|
|
3
|
+
*
|
|
4
|
+
* Tests the IntentSession class that provides conversational
|
|
5
|
+
* intent completeness checking before submission to Core.
|
|
6
|
+
*
|
|
7
|
+
* Acceptance criteria:
|
|
8
|
+
* F22: IntentSession.submit(text) accumulates text and checks completeness
|
|
9
|
+
* F23: Returns { complete, missing, question, confidence } from submit()
|
|
10
|
+
* F24: When complete === true, result includes completeness attestation
|
|
11
|
+
* F25: Maintains previousRounds context across submit() calls
|
|
12
|
+
* F26: State machine: idle → checking → incomplete/complete
|
|
13
|
+
* F27: submit() on a complete session returns same result without re-checking
|
|
14
|
+
* F28: reset() clears state back to idle
|
|
15
|
+
* F29: getText() returns accumulated text
|
|
16
|
+
* F30: Works without provider (regex-only fallback)
|
|
17
|
+
* F31: Emits activity events
|
|
18
|
+
* F32: Handles empty/whitespace text gracefully
|
|
19
|
+
* F33: Handles very long text (>5000 chars) gracefully
|
|
20
|
+
* F34: Multiple submit() calls accumulate text correctly
|
|
21
|
+
*
|
|
22
|
+
* Ref: ADR-002, DEC-024, DEC-025
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
|
|
28
|
+
import { IntentSession } from '../intent-session.js';
|
|
29
|
+
|
|
30
|
+
// ── F22: submit() accumulates text and checks completeness ───
|
|
31
|
+
|
|
32
|
+
describe('IntentSession.submit() — basic behavior (F22)', () => {
|
|
33
|
+
|
|
34
|
+
it('accepts text and returns a completeness result', async () => {
|
|
35
|
+
const session = new IntentSession();
|
|
36
|
+
const result = await session.submit('I need 50 ergonomic keyboards under $5000');
|
|
37
|
+
|
|
38
|
+
assert.equal(typeof result.complete, 'boolean');
|
|
39
|
+
assert.ok(Array.isArray(result.missing));
|
|
40
|
+
assert.equal(typeof result.question, 'string');
|
|
41
|
+
assert.equal(typeof result.confidence, 'number');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('accumulates text across multiple submit() calls', async () => {
|
|
45
|
+
const session = new IntentSession();
|
|
46
|
+
await session.submit('I need keyboards');
|
|
47
|
+
await session.submit('50 units under $5000');
|
|
48
|
+
|
|
49
|
+
assert.ok(session.getText().includes('I need keyboards'));
|
|
50
|
+
assert.ok(session.getText().includes('50 units under $5000'));
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── F23: Returns { complete, missing, question, confidence } ──
|
|
55
|
+
|
|
56
|
+
describe('IntentSession.submit() — return shape (F23)', () => {
|
|
57
|
+
|
|
58
|
+
it('returns all required fields', async () => {
|
|
59
|
+
const session = new IntentSession();
|
|
60
|
+
const result = await session.submit('stuff');
|
|
61
|
+
|
|
62
|
+
assert.ok('complete' in result, 'Should have complete');
|
|
63
|
+
assert.ok('missing' in result, 'Should have missing');
|
|
64
|
+
assert.ok('question' in result, 'Should have question');
|
|
65
|
+
assert.ok('confidence' in result, 'Should have confidence');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('missing is an array of category names', async () => {
|
|
69
|
+
const session = new IntentSession();
|
|
70
|
+
const result = await session.submit('stuff');
|
|
71
|
+
|
|
72
|
+
assert.ok(Array.isArray(result.missing));
|
|
73
|
+
if (result.missing.length > 0) {
|
|
74
|
+
assert.equal(typeof result.missing[0], 'string');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('question is a natural language string (or empty when complete)', async () => {
|
|
79
|
+
const session = new IntentSession();
|
|
80
|
+
const result = await session.submit('stuff');
|
|
81
|
+
|
|
82
|
+
assert.equal(typeof result.question, 'string');
|
|
83
|
+
// For incomplete intents, question should not be empty
|
|
84
|
+
if (!result.complete) {
|
|
85
|
+
assert.ok(result.question.length > 0, 'Should have a question for incomplete intent');
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// ── F24: Complete intent includes attestation ────────────────
|
|
91
|
+
|
|
92
|
+
describe('IntentSession — completeness attestation (F24)', () => {
|
|
93
|
+
|
|
94
|
+
it('getAttestation() returns null before completion', async () => {
|
|
95
|
+
const session = new IntentSession();
|
|
96
|
+
await session.submit('stuff');
|
|
97
|
+
|
|
98
|
+
const att = session.getAttestation();
|
|
99
|
+
assert.equal(att, null);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('getAttestation() returns attestation object when complete', async () => {
|
|
103
|
+
const session = new IntentSession();
|
|
104
|
+
// Provide a rich enough intent to satisfy Tier 1 categories
|
|
105
|
+
await session.submit('I need 50 ergonomic wireless keyboards under $5000 per unit');
|
|
106
|
+
|
|
107
|
+
if (session.state === 'complete') {
|
|
108
|
+
const att = session.getAttestation();
|
|
109
|
+
assert.ok(att, 'Should have attestation when complete');
|
|
110
|
+
assert.equal(att.complete, true);
|
|
111
|
+
assert.ok(Array.isArray(att.missing));
|
|
112
|
+
assert.equal(typeof att.confidence, 'number');
|
|
113
|
+
}
|
|
114
|
+
// If not complete with regex-only, that's OK — the attestation logic is still testable
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ── F25: Maintains previousRounds context ────────────────────
|
|
119
|
+
|
|
120
|
+
describe('IntentSession — conversation context (F25)', () => {
|
|
121
|
+
|
|
122
|
+
it('tracks previous rounds for clarification context', async () => {
|
|
123
|
+
const session = new IntentSession();
|
|
124
|
+
await session.submit('I need keyboards');
|
|
125
|
+
await session.submit('50 units');
|
|
126
|
+
|
|
127
|
+
assert.ok(session.roundCount >= 2, 'Should track multiple rounds');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('passes previousRounds to clarification generator', async () => {
|
|
131
|
+
const session = new IntentSession();
|
|
132
|
+
const r1 = await session.submit('I need keyboards');
|
|
133
|
+
const r2 = await session.submit('50 units under $5000');
|
|
134
|
+
|
|
135
|
+
// Second round should have different (or fewer) missing categories
|
|
136
|
+
// because context accumulates
|
|
137
|
+
assert.ok(r2.missing.length <= r1.missing.length || r2.confidence >= r1.confidence,
|
|
138
|
+
'Second round should show progress');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── F26: State machine: idle → checking → incomplete/complete ─
|
|
143
|
+
|
|
144
|
+
describe('IntentSession — state machine (F26)', () => {
|
|
145
|
+
|
|
146
|
+
it('starts in idle state', () => {
|
|
147
|
+
const session = new IntentSession();
|
|
148
|
+
assert.equal(session.state, 'idle');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('transitions to incomplete after incomplete submit', async () => {
|
|
152
|
+
const session = new IntentSession();
|
|
153
|
+
await session.submit('stuff');
|
|
154
|
+
assert.equal(session.state, 'incomplete');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('transitions to complete when all categories satisfied', async () => {
|
|
158
|
+
const session = new IntentSession();
|
|
159
|
+
// Very rich intent — should satisfy most Tier 1 categories
|
|
160
|
+
await session.submit('I need 50 ergonomic wireless keyboards at $100 each under $5000 total budget');
|
|
161
|
+
|
|
162
|
+
// State should be either incomplete or complete depending on detection
|
|
163
|
+
assert.ok(['incomplete', 'complete'].includes(session.state));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── F27: submit() on complete session returns cached result ──
|
|
168
|
+
|
|
169
|
+
describe('IntentSession — complete state caching (F27)', () => {
|
|
170
|
+
|
|
171
|
+
it('returns same result on subsequent submit() after completion', async () => {
|
|
172
|
+
const session = new IntentSession();
|
|
173
|
+
// Force complete state by providing rich intent
|
|
174
|
+
const r1 = await session.submit('I need 50 ergonomic wireless keyboards at $100 each under $5000');
|
|
175
|
+
|
|
176
|
+
if (session.state === 'complete') {
|
|
177
|
+
const r2 = await session.submit('more text that should be ignored');
|
|
178
|
+
assert.deepEqual(r1, r2, 'Should return same result after completion');
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ── F28: reset() clears state ────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('IntentSession.reset() (F28)', () => {
|
|
186
|
+
|
|
187
|
+
it('resets state back to idle', async () => {
|
|
188
|
+
const session = new IntentSession();
|
|
189
|
+
await session.submit('stuff');
|
|
190
|
+
assert.equal(session.state, 'incomplete');
|
|
191
|
+
|
|
192
|
+
session.reset();
|
|
193
|
+
assert.equal(session.state, 'idle');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('clears accumulated text', async () => {
|
|
197
|
+
const session = new IntentSession();
|
|
198
|
+
await session.submit('stuff');
|
|
199
|
+
session.reset();
|
|
200
|
+
assert.equal(session.getText(), '');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('resets round count', async () => {
|
|
204
|
+
const session = new IntentSession();
|
|
205
|
+
await session.submit('stuff');
|
|
206
|
+
session.reset();
|
|
207
|
+
assert.equal(session.roundCount, 0);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── F29: getText() returns accumulated text ──────────────────
|
|
212
|
+
|
|
213
|
+
describe('IntentSession.getText() (F29)', () => {
|
|
214
|
+
|
|
215
|
+
it('returns empty string before any submit', () => {
|
|
216
|
+
const session = new IntentSession();
|
|
217
|
+
assert.equal(session.getText(), '');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('returns all submitted text joined', async () => {
|
|
221
|
+
const session = new IntentSession();
|
|
222
|
+
await session.submit('I need keyboards');
|
|
223
|
+
await session.submit('50 units');
|
|
224
|
+
const text = session.getText();
|
|
225
|
+
assert.ok(text.includes('I need keyboards'));
|
|
226
|
+
assert.ok(text.includes('50 units'));
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── F30: Works without provider (regex-only) ─────────────────
|
|
231
|
+
|
|
232
|
+
describe('IntentSession — no provider (F30)', () => {
|
|
233
|
+
|
|
234
|
+
it('works without passing a provider (regex-only detection)', async () => {
|
|
235
|
+
const session = new IntentSession(); // no provider
|
|
236
|
+
const result = await session.submit('I need 50 keyboards under $5000');
|
|
237
|
+
|
|
238
|
+
assert.equal(typeof result.complete, 'boolean');
|
|
239
|
+
assert.ok(Array.isArray(result.missing));
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// ── F32: Empty/whitespace text ───────────────────────────────
|
|
244
|
+
|
|
245
|
+
describe('IntentSession — edge cases (F32, F33)', () => {
|
|
246
|
+
|
|
247
|
+
it('handles empty string gracefully', async () => {
|
|
248
|
+
const session = new IntentSession();
|
|
249
|
+
const result = await session.submit('');
|
|
250
|
+
|
|
251
|
+
assert.equal(result.complete, false);
|
|
252
|
+
assert.ok(result.missing.length > 0, 'Empty text should have missing categories');
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('handles whitespace-only string gracefully', async () => {
|
|
256
|
+
const session = new IntentSession();
|
|
257
|
+
const result = await session.submit(' ');
|
|
258
|
+
|
|
259
|
+
assert.equal(result.complete, false);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// F33: Long text
|
|
263
|
+
it('handles text up to 5000 chars', async () => {
|
|
264
|
+
const session = new IntentSession();
|
|
265
|
+
const longText = 'I need 50 keyboards under $5000. '.repeat(150); // ~5000 chars
|
|
266
|
+
const result = await session.submit(longText);
|
|
267
|
+
|
|
268
|
+
assert.equal(typeof result.complete, 'boolean');
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ── F34: Multiple submit() accumulation ──────────────────────
|
|
273
|
+
|
|
274
|
+
describe('IntentSession — multi-round accumulation (F34)', () => {
|
|
275
|
+
|
|
276
|
+
it('each submit adds to the accumulated context', async () => {
|
|
277
|
+
const session = new IntentSession();
|
|
278
|
+
|
|
279
|
+
const r1 = await session.submit('I need office supplies');
|
|
280
|
+
assert.ok(r1.missing.length > 0, 'First round should have missing');
|
|
281
|
+
|
|
282
|
+
const r2 = await session.submit('50 units of keyboards');
|
|
283
|
+
// Should have equal or fewer missing after adding quantity info
|
|
284
|
+
|
|
285
|
+
const r3 = await session.submit('budget is under $5000');
|
|
286
|
+
// Should have equal or fewer missing after adding price info
|
|
287
|
+
|
|
288
|
+
// Confidence should generally increase as we add more info
|
|
289
|
+
assert.ok(r3.confidence >= r1.confidence - 0.1,
|
|
290
|
+
'Confidence should not drastically decrease');
|
|
291
|
+
});
|
|
292
|
+
});
|