@ophirai/sdk 0.1.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.
Files changed (60) hide show
  1. package/README.md +139 -0
  2. package/dist/__tests__/buyer.test.d.ts +1 -0
  3. package/dist/__tests__/buyer.test.js +664 -0
  4. package/dist/__tests__/discovery.test.d.ts +1 -0
  5. package/dist/__tests__/discovery.test.js +188 -0
  6. package/dist/__tests__/escrow.test.d.ts +1 -0
  7. package/dist/__tests__/escrow.test.js +385 -0
  8. package/dist/__tests__/identity.test.d.ts +1 -0
  9. package/dist/__tests__/identity.test.js +222 -0
  10. package/dist/__tests__/integration.test.d.ts +1 -0
  11. package/dist/__tests__/integration.test.js +681 -0
  12. package/dist/__tests__/lockstep.test.d.ts +1 -0
  13. package/dist/__tests__/lockstep.test.js +320 -0
  14. package/dist/__tests__/messages.test.d.ts +1 -0
  15. package/dist/__tests__/messages.test.js +976 -0
  16. package/dist/__tests__/negotiation.test.d.ts +1 -0
  17. package/dist/__tests__/negotiation.test.js +667 -0
  18. package/dist/__tests__/seller.test.d.ts +1 -0
  19. package/dist/__tests__/seller.test.js +767 -0
  20. package/dist/__tests__/server.test.d.ts +1 -0
  21. package/dist/__tests__/server.test.js +239 -0
  22. package/dist/__tests__/signing.test.d.ts +1 -0
  23. package/dist/__tests__/signing.test.js +713 -0
  24. package/dist/__tests__/sla.test.d.ts +1 -0
  25. package/dist/__tests__/sla.test.js +342 -0
  26. package/dist/__tests__/transport.test.d.ts +1 -0
  27. package/dist/__tests__/transport.test.js +197 -0
  28. package/dist/__tests__/x402.test.d.ts +1 -0
  29. package/dist/__tests__/x402.test.js +141 -0
  30. package/dist/buyer.d.ts +190 -0
  31. package/dist/buyer.js +555 -0
  32. package/dist/discovery.d.ts +47 -0
  33. package/dist/discovery.js +51 -0
  34. package/dist/escrow.d.ts +177 -0
  35. package/dist/escrow.js +434 -0
  36. package/dist/identity.d.ts +60 -0
  37. package/dist/identity.js +108 -0
  38. package/dist/index.d.ts +122 -0
  39. package/dist/index.js +43 -0
  40. package/dist/lockstep.d.ts +94 -0
  41. package/dist/lockstep.js +127 -0
  42. package/dist/messages.d.ts +172 -0
  43. package/dist/messages.js +262 -0
  44. package/dist/negotiation.d.ts +113 -0
  45. package/dist/negotiation.js +214 -0
  46. package/dist/seller.d.ts +127 -0
  47. package/dist/seller.js +395 -0
  48. package/dist/server.d.ts +52 -0
  49. package/dist/server.js +149 -0
  50. package/dist/signing.d.ts +98 -0
  51. package/dist/signing.js +165 -0
  52. package/dist/sla.d.ts +95 -0
  53. package/dist/sla.js +187 -0
  54. package/dist/transport.d.ts +41 -0
  55. package/dist/transport.js +127 -0
  56. package/dist/types.d.ts +86 -0
  57. package/dist/types.js +1 -0
  58. package/dist/x402.d.ts +25 -0
  59. package/dist/x402.js +54 -0
  60. package/package.json +40 -0
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,667 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { NegotiationSession } from '../negotiation.js';
3
+ import { OphirErrorCode, OphirError } from '@ophirai/protocol';
4
+ function makeRFQ(overrides) {
5
+ return {
6
+ rfq_id: 'rfq-001',
7
+ buyer: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3000' },
8
+ service: { category: 'inference' },
9
+ budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
10
+ negotiation_style: 'rfq',
11
+ expires_at: new Date(Date.now() + 300_000).toISOString(),
12
+ signature: 'sig-placeholder',
13
+ ...overrides,
14
+ };
15
+ }
16
+ function makeQuote(overrides) {
17
+ return {
18
+ quote_id: 'quote-001',
19
+ rfq_id: 'rfq-001',
20
+ seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:4000' },
21
+ pricing: { price_per_unit: '0.008', currency: 'USDC', unit: 'request', pricing_model: 'fixed' },
22
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
23
+ signature: 'sig-placeholder',
24
+ ...overrides,
25
+ };
26
+ }
27
+ function makeCounter(round) {
28
+ return {
29
+ counter_id: `counter-${round}`,
30
+ rfq_id: 'rfq-001',
31
+ in_response_to: 'quote-001',
32
+ round,
33
+ from: { agent_id: 'did:key:z6MkTest', role: 'buyer' },
34
+ modifications: { price_per_unit: '0.007' },
35
+ expires_at: new Date(Date.now() + 120_000).toISOString(),
36
+ signature: 'sig-placeholder',
37
+ };
38
+ }
39
+ function makeAgreement() {
40
+ return {
41
+ agreement_id: 'agree-001',
42
+ rfq_id: 'rfq-001',
43
+ accepting_message_id: 'quote-001',
44
+ final_terms: { price_per_unit: '0.008', currency: 'USDC', unit: 'request' },
45
+ agreement_hash: 'abcd1234',
46
+ buyer_signature: 'buyer-sig',
47
+ seller_signature: 'seller-sig',
48
+ };
49
+ }
50
+ describe('NegotiationSession', () => {
51
+ let session;
52
+ beforeEach(() => {
53
+ session = new NegotiationSession(makeRFQ());
54
+ });
55
+ describe('initial state', () => {
56
+ it('starts in RFQ_SENT state', () => {
57
+ expect(session.state).toBe('RFQ_SENT');
58
+ });
59
+ it('initializes with rfq data', () => {
60
+ expect(session.rfqId).toBe('rfq-001');
61
+ expect(session.currentRound).toBe(0);
62
+ expect(session.maxRounds).toBe(5);
63
+ expect(session.quotes).toEqual([]);
64
+ expect(session.counters).toEqual([]);
65
+ });
66
+ it('respects custom maxRounds', () => {
67
+ const s = new NegotiationSession(makeRFQ(), 3);
68
+ expect(s.maxRounds).toBe(3);
69
+ });
70
+ it('uses rfq.max_rounds as fallback', () => {
71
+ const s = new NegotiationSession(makeRFQ({ max_rounds: 7 }));
72
+ expect(s.maxRounds).toBe(7);
73
+ });
74
+ });
75
+ describe('valid transitions', () => {
76
+ it('RFQ_SENT → QUOTES_RECEIVED via addQuote', () => {
77
+ session.addQuote(makeQuote());
78
+ expect(session.state).toBe('QUOTES_RECEIVED');
79
+ expect(session.quotes).toHaveLength(1);
80
+ });
81
+ it('QUOTES_RECEIVED → QUOTES_RECEIVED via addQuote (multiple quotes)', () => {
82
+ session.addQuote(makeQuote());
83
+ session.addQuote(makeQuote({ quote_id: 'quote-002' }));
84
+ expect(session.state).toBe('QUOTES_RECEIVED');
85
+ expect(session.quotes).toHaveLength(2);
86
+ });
87
+ it('QUOTES_RECEIVED → COUNTERING via addCounter', () => {
88
+ session.addQuote(makeQuote());
89
+ session.addCounter(makeCounter(1));
90
+ expect(session.state).toBe('COUNTERING');
91
+ expect(session.currentRound).toBe(1);
92
+ });
93
+ it('COUNTERING + addQuote accumulates seller response without changing state', () => {
94
+ session.addQuote(makeQuote());
95
+ session.addCounter(makeCounter(1));
96
+ expect(session.state).toBe('COUNTERING');
97
+ session.addQuote(makeQuote({ quote_id: 'response-quote' }));
98
+ expect(session.state).toBe('COUNTERING');
99
+ expect(session.quotes).toHaveLength(2);
100
+ });
101
+ it('COUNTERING → COUNTERING via addCounter', () => {
102
+ session.addQuote(makeQuote());
103
+ session.addCounter(makeCounter(1));
104
+ session.addCounter(makeCounter(2));
105
+ expect(session.state).toBe('COUNTERING');
106
+ expect(session.currentRound).toBe(2);
107
+ });
108
+ it('QUOTES_RECEIVED → ACCEPTED via accept', () => {
109
+ session.addQuote(makeQuote());
110
+ session.accept(makeAgreement());
111
+ expect(session.state).toBe('ACCEPTED');
112
+ expect(session.agreement).toBeDefined();
113
+ });
114
+ it('COUNTERING → ACCEPTED via accept', () => {
115
+ session.addQuote(makeQuote());
116
+ session.addCounter(makeCounter(1));
117
+ session.accept(makeAgreement());
118
+ expect(session.state).toBe('ACCEPTED');
119
+ });
120
+ it('ACCEPTED → ESCROWED via escrowFunded', () => {
121
+ session.addQuote(makeQuote());
122
+ session.accept(makeAgreement());
123
+ session.escrowFunded('escrow-address-123');
124
+ expect(session.state).toBe('ESCROWED');
125
+ });
126
+ it('ESCROWED → ACTIVE via activate', () => {
127
+ session.addQuote(makeQuote());
128
+ session.accept(makeAgreement());
129
+ session.escrowFunded('escrow-address-123');
130
+ session.activate();
131
+ expect(session.state).toBe('ACTIVE');
132
+ });
133
+ it('ACTIVE → COMPLETED via complete', () => {
134
+ session.addQuote(makeQuote());
135
+ session.accept(makeAgreement());
136
+ session.escrowFunded('escrow-address-123');
137
+ session.activate();
138
+ session.complete();
139
+ expect(session.state).toBe('COMPLETED');
140
+ });
141
+ it('ACTIVE → DISPUTED via dispute', () => {
142
+ session.addQuote(makeQuote());
143
+ session.accept(makeAgreement());
144
+ session.escrowFunded('escrow-address-123');
145
+ session.activate();
146
+ session.dispute();
147
+ expect(session.state).toBe('DISPUTED');
148
+ });
149
+ it('DISPUTED → RESOLVED via resolve', () => {
150
+ session.addQuote(makeQuote());
151
+ session.accept(makeAgreement());
152
+ session.escrowFunded('escrow-address-123');
153
+ session.activate();
154
+ session.dispute();
155
+ session.resolve();
156
+ expect(session.state).toBe('RESOLVED');
157
+ });
158
+ it('reject from RFQ_SENT', () => {
159
+ session.reject('no budget');
160
+ expect(session.state).toBe('REJECTED');
161
+ });
162
+ it('reject from QUOTES_RECEIVED', () => {
163
+ session.addQuote(makeQuote());
164
+ session.reject('too expensive');
165
+ expect(session.state).toBe('REJECTED');
166
+ });
167
+ it('reject from COUNTERING', () => {
168
+ session.addQuote(makeQuote());
169
+ session.addCounter(makeCounter(1));
170
+ session.reject('cannot agree');
171
+ expect(session.state).toBe('REJECTED');
172
+ });
173
+ it('full happy path: RFQ → quote → accept → escrow → active → complete', () => {
174
+ session.addQuote(makeQuote());
175
+ session.accept(makeAgreement());
176
+ session.escrowFunded('escrow-addr');
177
+ session.activate();
178
+ session.complete();
179
+ expect(session.state).toBe('COMPLETED');
180
+ });
181
+ });
182
+ describe('invalid transitions', () => {
183
+ it('cannot addQuote from ACCEPTED', () => {
184
+ session.addQuote(makeQuote());
185
+ session.accept(makeAgreement());
186
+ expect(() => session.addQuote(makeQuote())).toThrow();
187
+ try {
188
+ session.addQuote(makeQuote());
189
+ }
190
+ catch (e) {
191
+ expect(e).toBeInstanceOf(OphirError);
192
+ const err = e;
193
+ expect(err.code).toBe(OphirErrorCode.INVALID_STATE_TRANSITION);
194
+ }
195
+ });
196
+ it('cannot addCounter from RFQ_SENT', () => {
197
+ expect(() => session.addCounter(makeCounter(1))).toThrow();
198
+ });
199
+ it('cannot accept from RFQ_SENT', () => {
200
+ expect(() => session.accept(makeAgreement())).toThrow();
201
+ });
202
+ it('cannot escrowFunded from QUOTES_RECEIVED', () => {
203
+ session.addQuote(makeQuote());
204
+ expect(() => session.escrowFunded('addr')).toThrow();
205
+ });
206
+ it('cannot activate from ACCEPTED', () => {
207
+ session.addQuote(makeQuote());
208
+ session.accept(makeAgreement());
209
+ expect(() => session.activate()).toThrow();
210
+ });
211
+ it('cannot complete from ESCROWED', () => {
212
+ session.addQuote(makeQuote());
213
+ session.accept(makeAgreement());
214
+ session.escrowFunded('addr');
215
+ expect(() => session.complete()).toThrow();
216
+ });
217
+ it('cannot dispute from COMPLETED', () => {
218
+ session.addQuote(makeQuote());
219
+ session.accept(makeAgreement());
220
+ session.escrowFunded('addr');
221
+ session.activate();
222
+ session.complete();
223
+ expect(() => session.dispute()).toThrow();
224
+ });
225
+ it('cannot resolve from ACTIVE', () => {
226
+ session.addQuote(makeQuote());
227
+ session.accept(makeAgreement());
228
+ session.escrowFunded('addr');
229
+ session.activate();
230
+ expect(() => session.resolve()).toThrow();
231
+ });
232
+ it('can reject from ACCEPTED (counter-sign refused)', () => {
233
+ session.addQuote(makeQuote());
234
+ session.accept(makeAgreement());
235
+ // Protocol allows ACCEPTED → REJECTED (e.g., seller refuses to counter-sign)
236
+ session.reject('counter-sign refused');
237
+ expect(session.state).toBe('REJECTED');
238
+ });
239
+ it('cannot accept from REJECTED', () => {
240
+ session.reject('not interested');
241
+ expect(() => session.accept(makeAgreement())).toThrow();
242
+ try {
243
+ session.accept(makeAgreement());
244
+ }
245
+ catch (e) {
246
+ expect(e).toBeInstanceOf(OphirError);
247
+ const err = e;
248
+ expect(err.code).toBe(OphirErrorCode.INVALID_STATE_TRANSITION);
249
+ }
250
+ });
251
+ it('cannot escrowFunded from RFQ_SENT', () => {
252
+ expect(() => session.escrowFunded('addr')).toThrow();
253
+ try {
254
+ session.escrowFunded('addr');
255
+ }
256
+ catch (e) {
257
+ expect(e).toBeInstanceOf(OphirError);
258
+ const err = e;
259
+ expect(err.code).toBe(OphirErrorCode.INVALID_STATE_TRANSITION);
260
+ }
261
+ });
262
+ it('throws OphirError with INVALID_STATE_TRANSITION code', () => {
263
+ try {
264
+ session.addCounter(makeCounter(1));
265
+ expect.unreachable('should have thrown');
266
+ }
267
+ catch (e) {
268
+ expect(e).toBeInstanceOf(OphirError);
269
+ const err = e;
270
+ expect(err.code).toBe(OphirErrorCode.INVALID_STATE_TRANSITION);
271
+ expect(err.data.currentState).toBe('RFQ_SENT');
272
+ }
273
+ });
274
+ });
275
+ describe('round tracking', () => {
276
+ it('increments round on each counter', () => {
277
+ session.addQuote(makeQuote());
278
+ expect(session.currentRound).toBe(0);
279
+ session.addCounter(makeCounter(1));
280
+ expect(session.currentRound).toBe(1);
281
+ session.addCounter(makeCounter(2));
282
+ expect(session.currentRound).toBe(2);
283
+ });
284
+ it('throws MAX_ROUNDS_EXCEEDED when limit reached', () => {
285
+ const s = new NegotiationSession(makeRFQ(), 2);
286
+ s.addQuote(makeQuote());
287
+ s.addCounter(makeCounter(1));
288
+ s.addCounter(makeCounter(2));
289
+ try {
290
+ s.addCounter(makeCounter(3));
291
+ expect.unreachable('should have thrown');
292
+ }
293
+ catch (e) {
294
+ expect(e).toBeInstanceOf(OphirError);
295
+ const err = e;
296
+ expect(err.code).toBe(OphirErrorCode.MAX_ROUNDS_EXCEEDED);
297
+ }
298
+ });
299
+ });
300
+ describe('expiration', () => {
301
+ it('returns false for states without timeouts', () => {
302
+ session.addQuote(makeQuote());
303
+ session.accept(makeAgreement());
304
+ expect(session.isExpired()).toBe(false);
305
+ });
306
+ it('returns false when within timeout', () => {
307
+ expect(session.isExpired()).toBe(false);
308
+ });
309
+ it('returns true when past timeout', () => {
310
+ // Manually backdate updatedAt
311
+ session.updatedAt = new Date(Date.now() - 6 * 60 * 1000); // 6 min ago
312
+ expect(session.isExpired()).toBe(true); // RFQ_SENT timeout is 5 min
313
+ });
314
+ });
315
+ describe('serialization', () => {
316
+ it('serializes to JSON with all fields', () => {
317
+ session.addQuote(makeQuote());
318
+ session.addCounter(makeCounter(1));
319
+ const json = session.toJSON();
320
+ expect(json['rfqId']).toBe('rfq-001');
321
+ expect(json['state']).toBe('COUNTERING');
322
+ expect(json['quotes'].length).toBe(1);
323
+ expect(json['counters'].length).toBe(1);
324
+ expect(json['currentRound']).toBe(1);
325
+ expect(json['maxRounds']).toBe(5);
326
+ expect(json['createdAt']).toBeDefined();
327
+ expect(json['updatedAt']).toBeDefined();
328
+ });
329
+ it('includes agreement when accepted', () => {
330
+ session.addQuote(makeQuote());
331
+ session.accept(makeAgreement());
332
+ const json = session.toJSON();
333
+ expect(json['agreement']['agreement_id']).toBe('agree-001');
334
+ });
335
+ it('serializes dates as ISO strings', () => {
336
+ const json = session.toJSON();
337
+ expect(json['createdAt']).toMatch(/^\d{4}-\d{2}-\d{2}T/);
338
+ expect(json['updatedAt']).toMatch(/^\d{4}-\d{2}-\d{2}T/);
339
+ });
340
+ });
341
+ describe('edge cases', () => {
342
+ it('addQuote with mismatched rfq_id is accepted (no rfq_id validation)', () => {
343
+ const mismatchedQuote = makeQuote({ rfq_id: 'rfq-999', quote_id: 'quote-mismatch' });
344
+ session.addQuote(mismatchedQuote);
345
+ expect(session.state).toBe('QUOTES_RECEIVED');
346
+ expect(session.quotes).toHaveLength(1);
347
+ expect(session.quotes[0].rfq_id).toBe('rfq-999');
348
+ });
349
+ it('duplicate quote IDs are both stored (no deduplication)', () => {
350
+ session.addQuote(makeQuote({ quote_id: 'quote-dup' }));
351
+ session.addQuote(makeQuote({ quote_id: 'quote-dup' }));
352
+ expect(session.quotes).toHaveLength(2);
353
+ expect(session.quotes[0].quote_id).toBe('quote-dup');
354
+ expect(session.quotes[1].quote_id).toBe('quote-dup');
355
+ });
356
+ it('accept with mismatched rfq_id in agreement is accepted (no rfq_id validation)', () => {
357
+ session.addQuote(makeQuote());
358
+ const mismatchedAgreement = makeAgreement();
359
+ mismatchedAgreement.rfq_id = 'rfq-wrong';
360
+ session.accept(mismatchedAgreement);
361
+ expect(session.state).toBe('ACCEPTED');
362
+ expect(session.agreement.rfq_id).toBe('rfq-wrong');
363
+ });
364
+ it('double reject throws on the second call', () => {
365
+ session.reject('first rejection');
366
+ expect(session.state).toBe('REJECTED');
367
+ expect(session.rejectionReason).toBe('first rejection');
368
+ try {
369
+ session.reject('second rejection');
370
+ expect.unreachable('should have thrown');
371
+ }
372
+ catch (e) {
373
+ expect(e).toBeInstanceOf(OphirError);
374
+ const err = e;
375
+ expect(err.code).toBe(OphirErrorCode.INVALID_STATE_TRANSITION);
376
+ expect(err.data.currentState).toBe('REJECTED');
377
+ }
378
+ // reason should remain from first reject
379
+ expect(session.rejectionReason).toBe('first rejection');
380
+ });
381
+ it('maxRounds = 1 allows 1 counter then rejects the 2nd', () => {
382
+ const s = new NegotiationSession(makeRFQ(), 1);
383
+ s.addQuote(makeQuote());
384
+ s.addCounter(makeCounter(1));
385
+ expect(s.currentRound).toBe(1);
386
+ expect(s.state).toBe('COUNTERING');
387
+ try {
388
+ s.addCounter(makeCounter(2));
389
+ expect.unreachable('should have thrown');
390
+ }
391
+ catch (e) {
392
+ expect(e).toBeInstanceOf(OphirError);
393
+ const err = e;
394
+ expect(err.code).toBe(OphirErrorCode.MAX_ROUNDS_EXCEEDED);
395
+ }
396
+ });
397
+ it('updatedAt changes on state transitions', async () => {
398
+ const initialUpdatedAt = session.updatedAt.getTime();
399
+ // small delay to ensure timestamp differs
400
+ await new Promise((r) => setTimeout(r, 10));
401
+ session.addQuote(makeQuote());
402
+ const afterQuote = session.updatedAt.getTime();
403
+ expect(afterQuote).toBeGreaterThan(initialUpdatedAt);
404
+ await new Promise((r) => setTimeout(r, 10));
405
+ session.addCounter(makeCounter(1));
406
+ const afterCounter = session.updatedAt.getTime();
407
+ expect(afterCounter).toBeGreaterThan(afterQuote);
408
+ await new Promise((r) => setTimeout(r, 10));
409
+ session.accept(makeAgreement());
410
+ const afterAccept = session.updatedAt.getTime();
411
+ expect(afterAccept).toBeGreaterThan(afterCounter);
412
+ });
413
+ it('escrowFunded stores address accessible via toJSON', () => {
414
+ session.addQuote(makeQuote());
415
+ session.accept(makeAgreement());
416
+ session.escrowFunded('0xDeadBeef1234');
417
+ const json = session.toJSON();
418
+ expect(json['escrowAddress']).toBe('0xDeadBeef1234');
419
+ });
420
+ it('toJSON on COMPLETED session includes full lifecycle data', () => {
421
+ session.addQuote(makeQuote());
422
+ session.accept(makeAgreement());
423
+ session.escrowFunded('escrow-addr-final');
424
+ session.activate();
425
+ session.complete();
426
+ const json = session.toJSON();
427
+ expect(json['state']).toBe('COMPLETED');
428
+ expect(json['rfqId']).toBe('rfq-001');
429
+ expect(json['quotes'].length).toBe(1);
430
+ expect(json['agreement']).toBeDefined();
431
+ expect(json['agreement']['agreement_id']).toBe('agree-001');
432
+ expect(json['escrowAddress']).toBe('escrow-addr-final');
433
+ expect(json['rejectionReason']).toBeUndefined();
434
+ expect(json['createdAt']).toBeDefined();
435
+ expect(json['updatedAt']).toBeDefined();
436
+ });
437
+ it('toJSON on RESOLVED session includes dispute lifecycle data', () => {
438
+ session.addQuote(makeQuote());
439
+ session.accept(makeAgreement());
440
+ session.escrowFunded('escrow-addr-dispute');
441
+ session.activate();
442
+ session.dispute();
443
+ session.resolve();
444
+ const json = session.toJSON();
445
+ expect(json['state']).toBe('RESOLVED');
446
+ expect(json['rfqId']).toBe('rfq-001');
447
+ expect(json['agreement']).toBeDefined();
448
+ expect(json['escrowAddress']).toBe('escrow-addr-dispute');
449
+ expect(json['currentRound']).toBe(0);
450
+ });
451
+ it('reject from initial RFQ_SENT state (no quotes received)', () => {
452
+ // There is no IDLE state; RFQ_SENT is the initial state.
453
+ // Rejecting before any quotes is valid.
454
+ expect(session.state).toBe('RFQ_SENT');
455
+ session.reject('changed my mind');
456
+ expect(session.state).toBe('REJECTED');
457
+ expect(session.rejectionReason).toBe('changed my mind');
458
+ expect(session.quotes).toEqual([]);
459
+ expect(session.counters).toEqual([]);
460
+ expect(session.agreement).toBeUndefined();
461
+ });
462
+ });
463
+ });
464
+ describe('NegotiationSession additional coverage', () => {
465
+ // Helper to create a valid RFQ
466
+ function makeRFQ(overrides) {
467
+ return {
468
+ rfq_id: 'test-rfq-' + Math.random().toString(36).slice(2),
469
+ buyer: { agent_id: 'did:key:z6MkTest', endpoint: 'http://localhost:3001' },
470
+ service: { category: 'inference' },
471
+ budget: { max_price_per_unit: '0.01', currency: 'USDC', unit: 'request' },
472
+ negotiation_style: 'rfq',
473
+ expires_at: new Date(Date.now() + 300000).toISOString(),
474
+ signature: 'sig-placeholder',
475
+ ...overrides,
476
+ };
477
+ }
478
+ describe('state machine completeness', () => {
479
+ it('cannot addQuote from ACCEPTED state', () => {
480
+ const session = new NegotiationSession(makeRFQ());
481
+ // Need to get to ACCEPTED: add quote, then accept
482
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
483
+ session.addQuote(quote);
484
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
485
+ expect(() => session.addQuote(quote)).toThrow('ACCEPTED');
486
+ });
487
+ it('cannot addCounter from ACCEPTED state', () => {
488
+ const session = new NegotiationSession(makeRFQ());
489
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
490
+ session.addQuote(quote);
491
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
492
+ expect(() => session.addCounter({ counter_id: 'c1', rfq_id: session.rfqId, in_response_to: 'q1', round: 1, from: { agent_id: 'did:key:z6MkTest', role: 'buyer' }, modifications: {}, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' })).toThrow('ACCEPTED');
493
+ });
494
+ it('cannot accept from REJECTED state', () => {
495
+ const session = new NegotiationSession(makeRFQ());
496
+ session.reject('no thanks');
497
+ expect(() => session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' })).toThrow('REJECTED');
498
+ });
499
+ it('cannot escrowFunded from QUOTES_RECEIVED state', () => {
500
+ const session = new NegotiationSession(makeRFQ());
501
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
502
+ session.addQuote(quote);
503
+ expect(() => session.escrowFunded('addr')).toThrow('QUOTES_RECEIVED');
504
+ });
505
+ it('cannot activate from ACCEPTED state (must be ESCROWED)', () => {
506
+ const session = new NegotiationSession(makeRFQ());
507
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
508
+ session.addQuote(quote);
509
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
510
+ expect(() => session.activate()).toThrow('ACCEPTED');
511
+ });
512
+ it('cannot complete from ESCROWED state (must be ACTIVE)', () => {
513
+ const session = new NegotiationSession(makeRFQ());
514
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
515
+ session.addQuote(quote);
516
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
517
+ session.escrowFunded('addr');
518
+ expect(() => session.complete()).toThrow('ESCROWED');
519
+ });
520
+ it('cannot dispute from COMPLETED state', () => {
521
+ const session = new NegotiationSession(makeRFQ());
522
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
523
+ session.addQuote(quote);
524
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
525
+ session.escrowFunded('addr');
526
+ session.activate();
527
+ session.complete();
528
+ expect(() => session.dispute()).toThrow('COMPLETED');
529
+ });
530
+ it('cannot resolve from ACTIVE state (must be DISPUTED)', () => {
531
+ const session = new NegotiationSession(makeRFQ());
532
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
533
+ session.addQuote(quote);
534
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
535
+ session.escrowFunded('addr');
536
+ session.activate();
537
+ expect(() => session.resolve()).toThrow('ACTIVE');
538
+ });
539
+ it('full lifecycle: RFQ_SENT -> QUOTES_RECEIVED -> ACCEPTED -> ESCROWED -> ACTIVE -> DISPUTED -> RESOLVED', () => {
540
+ const session = new NegotiationSession(makeRFQ());
541
+ expect(session.state).toBe('RFQ_SENT');
542
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
543
+ session.addQuote(quote);
544
+ expect(session.state).toBe('QUOTES_RECEIVED');
545
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
546
+ expect(session.state).toBe('ACCEPTED');
547
+ session.escrowFunded('escrow-address');
548
+ expect(session.state).toBe('ESCROWED');
549
+ session.activate();
550
+ expect(session.state).toBe('ACTIVE');
551
+ session.dispute();
552
+ expect(session.state).toBe('DISPUTED');
553
+ session.resolve();
554
+ expect(session.state).toBe('RESOLVED');
555
+ });
556
+ it('full lifecycle: RFQ_SENT -> QUOTES_RECEIVED -> ACCEPTED -> ESCROWED -> ACTIVE -> COMPLETED', () => {
557
+ const session = new NegotiationSession(makeRFQ());
558
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
559
+ session.addQuote(quote);
560
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
561
+ session.escrowFunded('escrow-address');
562
+ session.activate();
563
+ session.complete();
564
+ expect(session.state).toBe('COMPLETED');
565
+ });
566
+ });
567
+ describe('error code verification', () => {
568
+ it('invalid transition throws OphirError with INVALID_STATE_TRANSITION code', () => {
569
+ const session = new NegotiationSession(makeRFQ());
570
+ try {
571
+ session.complete();
572
+ expect.fail('should have thrown');
573
+ }
574
+ catch (e) {
575
+ const err = e;
576
+ expect(err.code).toBe('OPHIR_004');
577
+ expect(err.data).toHaveProperty('currentState', 'RFQ_SENT');
578
+ expect(err.data).toHaveProperty('targetState');
579
+ }
580
+ });
581
+ it('max rounds exceeded throws OphirError with MAX_ROUNDS_EXCEEDED code', () => {
582
+ const session = new NegotiationSession(makeRFQ(), 1);
583
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
584
+ session.addQuote(quote);
585
+ session.addCounter({ counter_id: 'c1', rfq_id: session.rfqId, in_response_to: 'q1', round: 1, from: { agent_id: 'did:key:z6MkTest', role: 'buyer' }, modifications: {}, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' });
586
+ try {
587
+ session.addCounter({ counter_id: 'c2', rfq_id: session.rfqId, in_response_to: 'q1', round: 2, from: { agent_id: 'did:key:z6MkTest', role: 'buyer' }, modifications: {}, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' });
588
+ expect.fail('should have thrown');
589
+ }
590
+ catch (e) {
591
+ const err = e;
592
+ expect(err.code).toBe('OPHIR_005');
593
+ }
594
+ });
595
+ });
596
+ describe('getEscrowAddress', () => {
597
+ it('returns undefined before escrow is funded', () => {
598
+ const session = new NegotiationSession(makeRFQ());
599
+ expect(session.getEscrowAddress()).toBeUndefined();
600
+ });
601
+ it('returns address after escrow is funded', () => {
602
+ const session = new NegotiationSession(makeRFQ());
603
+ const quote = { quote_id: 'q1', rfq_id: session.rfqId, seller: { agent_id: 'did:key:z6MkSeller', endpoint: 'http://localhost:3000' }, pricing: { price_per_unit: '0.01', currency: 'USDC', unit: 'request', pricing_model: 'fixed' }, expires_at: new Date(Date.now() + 60000).toISOString(), signature: 'sig' };
604
+ session.addQuote(quote);
605
+ session.accept({ agreement_id: 'a1', rfq_id: session.rfqId, accepting_message_id: 'quote-001', final_terms: { price_per_unit: '0.01', currency: 'USDC', unit: 'request' }, agreement_hash: 'h', buyer_signature: 'bs', seller_signature: 'ss' });
606
+ session.escrowFunded('3xj4Y7qnPETiQwNmfUZgk5GsrFMz1Yk8LgMpRbJ7EfP');
607
+ expect(session.getEscrowAddress()).toBe('3xj4Y7qnPETiQwNmfUZgk5GsrFMz1Yk8LgMpRbJ7EfP');
608
+ });
609
+ });
610
+ describe('isTerminal', () => {
611
+ it('returns false for non-terminal states', () => {
612
+ const session = new NegotiationSession(makeRFQ());
613
+ expect(session.isTerminal()).toBe(false); // RFQ_SENT
614
+ session.addQuote(makeQuote());
615
+ expect(session.isTerminal()).toBe(false); // QUOTES_RECEIVED
616
+ });
617
+ it('returns true for REJECTED', () => {
618
+ const session = new NegotiationSession(makeRFQ());
619
+ session.reject('no thanks');
620
+ expect(session.isTerminal()).toBe(true);
621
+ });
622
+ it('returns true for COMPLETED', () => {
623
+ const session = new NegotiationSession(makeRFQ());
624
+ session.addQuote(makeQuote());
625
+ session.accept(makeAgreement());
626
+ session.escrowFunded('addr');
627
+ session.activate();
628
+ session.complete();
629
+ expect(session.isTerminal()).toBe(true);
630
+ });
631
+ it('returns true for RESOLVED', () => {
632
+ const session = new NegotiationSession(makeRFQ());
633
+ session.addQuote(makeQuote());
634
+ session.accept(makeAgreement());
635
+ session.escrowFunded('addr');
636
+ session.activate();
637
+ session.dispute();
638
+ session.resolve();
639
+ expect(session.isTerminal()).toBe(true);
640
+ });
641
+ });
642
+ describe('getValidNextStates', () => {
643
+ it('returns valid next states from RFQ_SENT', () => {
644
+ const session = new NegotiationSession(makeRFQ());
645
+ const next = session.getValidNextStates();
646
+ expect(next).toContain('QUOTES_RECEIVED');
647
+ expect(next).toContain('REJECTED');
648
+ expect(next).not.toContain('ACCEPTED');
649
+ });
650
+ it('returns empty array from terminal state', () => {
651
+ const session = new NegotiationSession(makeRFQ());
652
+ session.reject('done');
653
+ expect(session.getValidNextStates()).toHaveLength(0);
654
+ });
655
+ it('returns correct transitions from ACTIVE', () => {
656
+ const session = new NegotiationSession(makeRFQ());
657
+ session.addQuote(makeQuote());
658
+ session.accept(makeAgreement());
659
+ session.escrowFunded('addr');
660
+ session.activate();
661
+ const next = session.getValidNextStates();
662
+ expect(next).toContain('COMPLETED');
663
+ expect(next).toContain('DISPUTED');
664
+ expect(next).toHaveLength(2);
665
+ });
666
+ });
667
+ });