@modelprofile.com/flexharness 12.1.0 → 12.1.2

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.
@@ -6,6 +6,7 @@ import {
6
6
  } from '../ts/errors.js';
7
7
  import type {
8
8
  IFlexHarnessStores,
9
+ IFlexProjectManagementSessionContext,
9
10
  IFlexProjectManagementSnapshot,
10
11
  IFlexProjectManagementTombstone,
11
12
  IFlexProjectionChange,
@@ -46,6 +47,11 @@ export interface IFlexStoreContractBackend {
46
47
  releasesSessions: boolean;
47
48
  /** The bundle provides `results`. */
48
49
  storesResults: boolean;
50
+ /**
51
+ * The scope store keeps a session whose `scopeId` differs from the storage key it is saved under,
52
+ * as a resolver that maps several scope IDs to one storage key saves it. Defaults to `true`.
53
+ */
54
+ storesScopeAliases?: boolean;
49
55
  open(): Promise<IFlexStoreContractFixture>;
50
56
  }
51
57
 
@@ -86,6 +92,17 @@ function projectTombstone(
86
92
  };
87
93
  }
88
94
 
95
+ /**
96
+ * The session context FlexHarness passes with every project-management `load` and
97
+ * `tombstoneSession` of the session generation `generation`.
98
+ */
99
+ function projectSessionContext(
100
+ generation: string,
101
+ sequence = 1,
102
+ ): Readonly<IFlexProjectManagementSessionContext> {
103
+ return Object.freeze({ sessionGenerationId: generation, sessionGenerationSequence: sequence });
104
+ }
105
+
89
106
  /** Exactly one of `attempts` succeeded and another was rejected with `errorClass`. */
90
107
  function assertOneWinner(attempts: PromiseSettledResult<unknown>[], errorClass: TErrorClass): void {
91
108
  plugins.assert.equal(attempts.filter((result) => result.status === 'fulfilled').length, 1);
@@ -164,7 +181,7 @@ export function createFlexStoreContractTests(
164
181
 
165
182
  contractTest('scope store clones snapshots and enforces CAS', async ({ stores }) => {
166
183
  plugins.assert.equal(await stores.scopes.load('scope'), undefined);
167
- const first = scopeSnapshot(1);
184
+ const first = scopeSnapshot(1, 'session-1', 'scope');
168
185
  await stores.scopes.save('scope', first, 0);
169
186
  first.sessions[0].status = 'failed';
170
187
  const loaded = await stores.scopes.load('scope');
@@ -174,15 +191,15 @@ export function createFlexStoreContractTests(
174
191
  plugins.assert.equal((await stores.scopes.load('scope'))?.sessions[0].status, 'idle');
175
192
 
176
193
  assertOneWinner(await Promise.allSettled([
177
- stores.scopes.save('scope', scopeSnapshot(2, 'session-a'), 1),
178
- stores.scopes.save('scope', scopeSnapshot(2, 'session-b'), 1),
194
+ stores.scopes.save('scope', scopeSnapshot(2, 'session-a', 'scope'), 1),
195
+ stores.scopes.save('scope', scopeSnapshot(2, 'session-b', 'scope'), 1),
179
196
  ]), FlexHarnessStoreConflictError);
180
197
  plugins.assert.equal((await stores.scopes.load('scope'))?.revision, 2);
181
198
  });
182
199
 
183
200
  contractTest('saves require the next revision and report the stored one', async ({ stores }) => {
184
- await assertRejects(() => stores.scopes.save('scope', scopeSnapshot(2), 0), FlexHarnessStoreFormatError);
185
- await assertRejects(() => stores.scopes.save('scope', scopeSnapshot(0), -1), FlexHarnessValidationError);
201
+ await assertRejects(() => stores.scopes.save('scope', scopeSnapshot(2, 'session-1', 'scope'), 0), FlexHarnessStoreFormatError);
202
+ await assertRejects(() => stores.scopes.save('scope', scopeSnapshot(0, 'session-1', 'scope'), -1), FlexHarnessValidationError);
186
203
  plugins.assert.equal(await stores.scopes.load('scope'), undefined);
187
204
 
188
205
  const saveProjection = (revision: number, expectedRevision: number) => stores.projections.save(
@@ -228,7 +245,7 @@ export function createFlexStoreContractTests(
228
245
  });
229
246
 
230
247
  contractTest('split session domains stay independent', async ({ stores }) => {
231
- await stores.scopes.save('scope', scopeSnapshot(1), 0);
248
+ await stores.scopes.save('scope', scopeSnapshot(1, 'session-1', 'scope'), 0);
232
249
  await stores.projections.save('scope', 'session-1', createWholeProjectionChange(projectionSnapshot(1)), 0);
233
250
  await stores.permissions.save('scope', 'session-1', permissionSnapshot(1), 0);
234
251
  const eventStore = await stores.agentEvents.getStore('scope', 'session-1');
@@ -271,7 +288,7 @@ export function createFlexStoreContractTests(
271
288
  });
272
289
 
273
290
  contractTest('stores reject non-serializable snapshots at their boundary', async ({ stores }) => {
274
- const invalid = scopeSnapshot(1) as unknown as Record<string, unknown>;
291
+ const invalid = scopeSnapshot(1, 'session-1', 'scope') as unknown as Record<string, unknown>;
275
292
  invalid.nonJson = new Date();
276
293
  await assertRejects(
277
294
  () => stores.scopes.save('scope', invalid as unknown as IFlexScopeSnapshot, 0),
@@ -618,6 +635,8 @@ export function createFlexStoreContractTests(
618
635
  contractTest('project stores fence session generations and tombstones', async ({ stores }) => {
619
636
  const store = stores.projectManagement;
620
637
  const application = { actor: 'application' as const };
638
+ const generationA = projectSessionContext('generation-a');
639
+ const generationB = projectSessionContext('generation-b', 2);
621
640
  await store.save('namespace', 'session', liveProjectState('generation-a'), 0, application);
622
641
  await assertRejects(
623
642
  () => store.save('namespace', 'session', liveProjectState('generation-a'), 0, application),
@@ -629,9 +648,12 @@ export function createFlexStoreContractTests(
629
648
  toolCallId: 'tool-call',
630
649
  });
631
650
 
632
- await store.tombstoneSession('namespace', 'session', projectTombstone('generation-a', 3), 2);
633
- await store.tombstoneSession('namespace', 'session', projectTombstone('generation-a', 3), 2);
634
- plugins.assert.deepEqual(await store.load('namespace', 'session'), projectTombstone('generation-a', 3));
651
+ await store.tombstoneSession('namespace', 'session', projectTombstone('generation-a', 3), 2, generationA);
652
+ await store.tombstoneSession('namespace', 'session', projectTombstone('generation-a', 3), 2, generationA);
653
+ plugins.assert.deepEqual(
654
+ await store.load('namespace', 'session', generationA),
655
+ projectTombstone('generation-a', 3),
656
+ );
635
657
  await assertRejects(
636
658
  () => store.save('namespace', 'session', liveProjectState('generation-a', 4), 3, application),
637
659
  FlexHarnessStoreConflictError,
@@ -639,13 +661,13 @@ export function createFlexStoreContractTests(
639
661
 
640
662
  const replacement = liveProjectState('generation-b', 1, 2);
641
663
  await store.save('namespace', 'session', replacement, 0, application);
642
- plugins.assert.deepEqual(await store.load('namespace', 'session'), replacement);
664
+ plugins.assert.deepEqual(await store.load('namespace', 'session', generationB), replacement);
643
665
  await assertRejects(
644
666
  () => store.save('namespace', 'session', liveProjectState('generation-c'), 0, application),
645
667
  FlexHarnessStoreConflictError,
646
668
  );
647
669
  await assertRejects(
648
- () => store.tombstoneSession('namespace', 'session', projectTombstone('generation-a'), 0),
670
+ () => store.tombstoneSession('namespace', 'session', projectTombstone('generation-a'), 0, generationA),
649
671
  FlexHarnessStoreConflictError,
650
672
  );
651
673
  });
@@ -653,15 +675,21 @@ export function createFlexStoreContractTests(
653
675
  contractTest('project stores purge one exact namespace without affecting another', async ({ stores }) => {
654
676
  const store = stores.projectManagement;
655
677
  const application = { actor: 'application' as const };
678
+ const aLive = projectSessionContext('a-live');
679
+ const aTombstone = projectSessionContext('a-tombstone');
680
+ const bLive = projectSessionContext('b-live');
681
+ const bTombstone = projectSessionContext('b-tombstone');
682
+ const oldGeneration = projectSessionContext('old-generation');
656
683
  await store.save('namespace-a', 'same-id', liveProjectState('a-live'), 0, application);
657
- await store.tombstoneSession('namespace-a', 'tombstoned', projectTombstone('a-tombstone'), 0);
684
+ await store.tombstoneSession('namespace-a', 'tombstoned', projectTombstone('a-tombstone'), 0, aTombstone);
658
685
  await store.save('namespace-b', 'same-id', liveProjectState('b-live'), 0, application);
659
- await store.tombstoneSession('namespace-b', 'tombstoned', projectTombstone('b-tombstone'), 0);
686
+ await store.tombstoneSession('namespace-b', 'tombstoned', projectTombstone('b-tombstone'), 0, bTombstone);
660
687
  await store.tombstoneSession(
661
688
  'namespace-b',
662
689
  'generation-fence',
663
690
  projectTombstone('old-generation'),
664
691
  0,
692
+ oldGeneration,
665
693
  );
666
694
  await store.save(
667
695
  'namespace-b',
@@ -675,13 +703,19 @@ export function createFlexStoreContractTests(
675
703
  'generation-fence',
676
704
  projectTombstone('old-generation'),
677
705
  0,
706
+ oldGeneration,
678
707
  ), FlexHarnessStoreConflictError);
679
708
 
680
709
  await store.purgeNamespace('namespace-a');
681
- plugins.assert.equal(await store.load('namespace-a', 'same-id'), undefined);
682
- plugins.assert.equal(await store.load('namespace-a', 'tombstoned'), undefined);
683
- plugins.assert.notEqual(await store.load('namespace-b', 'same-id'), undefined);
684
- plugins.assert.notEqual(await store.load('namespace-b', 'tombstoned'), undefined);
710
+ plugins.assert.equal(await store.load('namespace-a', 'same-id', aLive), undefined);
711
+ plugins.assert.equal(await store.load('namespace-a', 'tombstoned', aTombstone), undefined);
712
+ plugins.assert.notEqual(await store.load('namespace-b', 'same-id', bLive), undefined);
713
+ plugins.assert.notEqual(await store.load('namespace-b', 'tombstoned', bTombstone), undefined);
714
+ });
715
+
716
+ if (backend.storesScopeAliases ?? true) contractTest('scope store keeps the sessions of scope aliases sharing its storage key', async ({ stores }) => {
717
+ await stores.scopes.save('scope', scopeSnapshot(1, 'session-1', 'alias'), 0);
718
+ plugins.assert.equal((await stores.scopes.load('scope'))?.sessions[0].scopeId, 'alias');
685
719
  });
686
720
 
687
721
  if (backend.storesResults) contractTest('result stores write each handle once, clone values, list and delete them', async ({ stores }) => {
@@ -738,8 +772,8 @@ export function createFlexStoreContractTests(
738
772
  const first = fixture.stores;
739
773
  const second = await fixture.openPeer();
740
774
  assertOneWinner(await Promise.allSettled([
741
- first.scopes.save('shared', scopeSnapshot(1, 'first'), 0),
742
- second.scopes.save('shared', scopeSnapshot(1, 'second'), 0),
775
+ first.scopes.save('shared', scopeSnapshot(1, 'first', 'shared'), 0),
776
+ second.scopes.save('shared', scopeSnapshot(1, 'second', 'shared'), 0),
743
777
  ]), FlexHarnessStoreConflictError);
744
778
  plugins.assert.deepEqual(await first.scopes.load('shared'), await second.scopes.load('shared'));
745
779
 
@@ -7,14 +7,14 @@ import type {
7
7
  IFlexScopeSnapshot,
8
8
  } from '../ts/interfaces.js';
9
9
 
10
- /** A scope snapshot with one idle session. */
11
- export function scopeSnapshot(revision: number, sessionId = 'session-1'): IFlexScopeSnapshot {
10
+ /** A scope snapshot with one idle session of `scopeId`. */
11
+ export function scopeSnapshot(revision: number, sessionId = 'session-1', scopeId = 'scope-1'): IFlexScopeSnapshot {
12
12
  return {
13
13
  schemaVersion: 1,
14
14
  revision,
15
15
  sessions: [
16
16
  {
17
- scopeId: 'scope-1',
17
+ scopeId,
18
18
  sessionId,
19
19
  createdAt: '2026-08-05T00:00:00.000Z',
20
20
  updatedAt: '2026-08-05T00:00:00.000Z',
@@ -31,6 +31,7 @@ for (const contractTest of createFlexStoreContractTests({
31
31
  - `sharedBackingStore`: several bundles can open one backing store, as separate processes would. The fixture's `openPeer()` opens another, and a case checks that the bundles share compare-and-swap revisions.
32
32
  - `releasesSessions`: the Agent event and tool job providers implement `releaseSession`, which must evict their session wrappers without deleting stored data.
33
33
  - `storesResults`: the bundle provides the optional `results` store.
34
+ - `storesScopeAliases` (optional, `true` when left out): the scope store keeps a session whose `scopeId` differs from the storage key it is saved under, as a scope resolver that maps several scope IDs to one storage key saves it. A store behind a resolver whose storage key always equals the scope ID may refuse such a session and declare `false`; every other case saves each session under its own scope ID.
34
35
 
35
36
  The cases cover compare-and-swap revisions and the conflict errors that report them, cloning at the store boundary, session and storage key isolation, the snapshot validation every domain applies, and the change-based saves of projections and Agent events: a projection change keeps the stored entries its list changes name and appends the rest, an Agent event change removes, replaces and appends events, and a change that does not fit the stored value never loads, whether the store rejects it when it saves the change or when it next loads the value. The cases await every store call, as FlexHarness does, so a store method may reject or throw.
36
37