@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.
@@ -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
+ });