@genesislcap/ai-assistant 15.15.1 → 15.15.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.
Files changed (31) hide show
  1. package/dist/ai-assistant.api.json +367 -0
  2. package/dist/ai-assistant.d.ts +129 -1
  3. package/dist/chat-driver.cjs +3 -0
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +3 -0
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +232 -3
  8. package/dist/dts/main/main.d.ts +129 -0
  9. package/dist/dts/main/main.d.ts.map +1 -1
  10. package/dist/dts/main/main.template.d.ts +0 -1
  11. package/dist/dts/main/main.template.d.ts.map +1 -1
  12. package/dist/dts/main/persistence-broken-sources.test.d.ts +2 -0
  13. package/dist/dts/main/persistence-broken-sources.test.d.ts.map +1 -0
  14. package/dist/dts/state/debug-event-log.d.ts +1 -1
  15. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  16. package/dist/dts/state/persistence/session-persister.d.ts +76 -0
  17. package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
  18. package/dist/esm/main/main.js +205 -0
  19. package/dist/esm/main/main.template.js +89 -0
  20. package/dist/esm/main/persistence-broken-sources.test.js +180 -0
  21. package/dist/esm/state/debug-event-log.js +3 -0
  22. package/dist/esm/state/persistence/session-persister.js +224 -43
  23. package/dist/esm/state/persistence/session-persister.test.js +237 -3
  24. package/dist/tsconfig.tsbuildinfo +1 -1
  25. package/package.json +17 -17
  26. package/src/main/main.template.ts +101 -0
  27. package/src/main/main.ts +230 -0
  28. package/src/main/persistence-broken-sources.test.ts +219 -0
  29. package/src/state/debug-event-log.ts +4 -0
  30. package/src/state/persistence/session-persister.test.ts +302 -33
  31. package/src/state/persistence/session-persister.ts +260 -52
@@ -1,5 +1,6 @@
1
1
  import type { ChatMessage } from '@genesislcap/foundation-ai';
2
2
  import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
3
+ import { clearMetaEventRegistry, getMetaEvents } from '../debug-event-log';
3
4
  import type { DiagnosticEntry } from './diagnostics';
4
5
  import { clearDiagnosticsCursorRegistry } from './diagnostics-cursors';
5
6
  import { clearPersisterRegistry } from './persister-registry';
@@ -42,6 +43,12 @@ interface Fakes {
42
43
  loadPrefsResult: SessionPreferences | undefined;
43
44
  /** Set false to omit `appendDiagnostics`/`savePreferences` from the provider. */
44
45
  diagnosticsCapable: boolean;
46
+ /** When set, `provider.save` rejects with it (GENC-1511 save-failure telemetry). */
47
+ saveError: Error | undefined;
48
+ /** Details passed to `onPersistenceBroken` (GENC-1511 user-facing warning). */
49
+ brokenReports: { unsavedMessages: number; lastSavedAt?: string; reason: string }[];
50
+ /** How many times `onPersistenceRecovered` fired. */
51
+ recovered: number;
45
52
  }
46
53
 
47
54
  function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fakes } {
@@ -62,12 +69,16 @@ function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fak
62
69
  savedPrefs: [],
63
70
  loadPrefsResult: undefined,
64
71
  diagnosticsCapable: true,
72
+ saveError: undefined,
73
+ brokenReports: [],
74
+ recovered: 0,
65
75
  ...over,
66
76
  };
67
77
  const provider = {
68
78
  saveDebounceMs: 0,
69
79
  diagnosticsDebounceMs: 0,
70
80
  save: async (_k: string, snap: PersistedSession) => {
81
+ if (f.saveError) throw f.saveError;
71
82
  f.saved.push(snap);
72
83
  },
73
84
  load: async () => f.loadResult,
@@ -130,6 +141,10 @@ function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fak
130
141
  getDriver: () => driver as never,
131
142
  getStore: () => store as never,
132
143
  getDiagnosticEntries: () => f.diagnosticEntries,
144
+ onPersistenceBroken: (d) => f.brokenReports.push(d),
145
+ onPersistenceRecovered: () => {
146
+ f.recovered += 1;
147
+ },
133
148
  };
134
149
  return { p: new SessionPersister(deps), f };
135
150
  }
@@ -139,6 +154,7 @@ const Suite = createLogicSuite('SessionPersister');
139
154
  Suite.before.each(() => {
140
155
  clearPersisterRegistry();
141
156
  clearDiagnosticsCursorRegistry();
157
+ clearMetaEventRegistry();
142
158
  });
143
159
 
144
160
  Suite('restore() hydrates the store + driver and appends a "Restored" divider', async () => {
@@ -280,42 +296,110 @@ Suite('scheduleSave() is a no-op when persistence is disabled', async () => {
280
296
  assert.is(f.saved.length, 0);
281
297
  });
282
298
 
283
- Suite('freeze: a non-resumable agent stamps the PRIOR snapshot, never an empty one', async () => {
284
- // Prior persisted (resumable) snapshot with real conversation.
285
- const prior = createPersistedSession({
286
- sessionKey: KEY,
287
- host: HOST,
288
- messages: [
289
- msg({ role: 'user', content: 'earlier' }),
290
- msg({ role: 'assistant', content: 'ok' }),
291
- ],
292
- pinnedAgentName: 'Trade Ops',
293
- flowOwnerAgentName: null,
294
- sessionCostUsd: 0,
295
- });
296
- const { p, f } = makePersister({
297
- loadResult: prior,
298
- // in-flight transcript has extra agent work we must NOT persist
299
- history: [
300
- msg({ role: 'user', content: 'earlier' }),
301
- msg({ role: 'assistant', content: 'ok' }),
302
- msg({ role: 'assistant', content: 'in-flight work' }),
303
- ],
304
- activeAgent: {
305
- name: 'Trade Ops',
306
- resumable: async () => false,
307
- notResumableMessage: 'start again',
308
- serialize: () => ({}),
309
- },
310
- });
299
+ Suite(
300
+ 'freeze: a non-resumable agent keeps the conversation and drops only in-flight work',
301
+ async () => {
302
+ // Prior persisted (resumable) snapshot with real conversation.
303
+ const prior = createPersistedSession({
304
+ sessionKey: KEY,
305
+ host: HOST,
306
+ messages: [
307
+ msg({ role: 'user', content: 'earlier' }),
308
+ msg({ role: 'assistant', content: 'ok' }),
309
+ ],
310
+ pinnedAgentName: 'Trade Ops',
311
+ flowOwnerAgentName: null,
312
+ sessionCostUsd: 0,
313
+ });
314
+ const { p, f } = makePersister({
315
+ loadResult: prior,
316
+ // in-flight transcript has extra agent work we must NOT persist
317
+ history: [
318
+ msg({ role: 'user', content: 'earlier' }),
319
+ msg({ role: 'assistant', content: 'ok' }),
320
+ msg({ role: 'assistant', content: 'in-flight work' }),
321
+ ],
322
+ activeAgent: {
323
+ name: 'Trade Ops',
324
+ resumable: async () => false,
325
+ notResumableMessage: 'start again',
326
+ serialize: () => ({}),
327
+ },
328
+ });
329
+ p.scheduleSave();
330
+ await tick();
331
+ await tick();
332
+ assert.is(f.saved.length, 1, 'one freeze-stamp write');
333
+ const written = f.saved[0];
334
+ assert.ok(written.interrupted, 'stamped with an interrupted marker');
335
+ assert.is(written.interrupted?.message, 'start again');
336
+ // GENC-1511: the boundary is the LIVE transcript through the last user message, floored
337
+ // at whatever was already persisted — never the stored blob, which is what silently
338
+ // rolled a session back three hours. Here the last user message is index 0, so the floor
339
+ // (nothing persisted by this persister yet) leaves 1; the in-flight assistant turn is
340
+ // dropped either way, which is the property that matters.
341
+ assert.is(written.messages.length, 1, 'in-flight agent work is not persisted');
342
+ assert.is(written.messages[0].content, 'earlier', 'the conversation itself is kept');
343
+ assert.is(written.flowOwnerAgentName, null, 'flow ownership dropped');
344
+ assert.equal(written.agentSnapshots, {}, 'flow state dropped');
345
+ },
346
+ );
347
+
348
+ Suite('freeze: the boundary never regresses below what was already persisted', async () => {
349
+ // A resumable save lands the full transcript; the agent then goes non-resumable WITHOUT
350
+ // a new user prompt (auto-advance, a resumed journey step). `keepThroughLastUserMessage`
351
+ // alone would rewind to message 1 and drop the completed exchange — the floor stops it.
352
+ const history = [
353
+ msg({ role: 'user', content: 'earlier' }),
354
+ msg({ role: 'assistant', content: 'ok' }),
355
+ ];
356
+ const agent = { name: 'Trade Ops', resumable: async () => true, serialize: () => ({}) };
357
+ const { p, f } = makePersister({ history, activeAgent: agent });
358
+ p.scheduleSave();
359
+ await tick();
360
+ assert.is(f.saved.length, 1, 'the resumable save landed');
361
+ assert.is(f.saved[0].messages.length, 2, 'full transcript persisted');
362
+
363
+ agent.resumable = async () => false;
364
+ history.push(msg({ role: 'assistant', content: 'in-flight work' }));
365
+ p.scheduleSave();
366
+ await tick();
367
+ await tick();
368
+ assert.is(f.saved.length, 2, 'the freeze wrote');
369
+ assert.is(f.saved[1].messages.length, 2, 'floored at the already-persisted length');
370
+ assert.ok(f.saved[1].interrupted, 'and stamped interrupted');
371
+ });
372
+
373
+ Suite('freeze: an unmoved boundary skips the write, and never latches', async () => {
374
+ const history = [
375
+ msg({ role: 'user', content: 'do it' }),
376
+ msg({ role: 'assistant', content: 'working' }),
377
+ ];
378
+ const agent = {
379
+ name: 'Trade Ops',
380
+ resumable: async () => false,
381
+ serialize: () => ({}),
382
+ };
383
+ const { p, f } = makePersister({ history, activeAgent: agent });
311
384
  p.scheduleSave();
312
385
  await tick();
313
386
  await tick();
314
- assert.is(f.saved.length, 1, 'one freeze-stamp write');
315
- const written = f.saved[0];
316
- assert.ok(written.interrupted, 'stamped with an interrupted marker');
317
- assert.is(written.interrupted?.message, 'start again');
318
- assert.is(written.messages.length, 2, 'froze at the PRIOR transcript, not the in-flight one');
387
+ assert.is(f.saved.length, 1, 'first freeze writes the boundary');
388
+
389
+ // More in-flight turns, same last user message → nothing new to freeze, no write.
390
+ history.push(msg({ role: 'assistant', content: 'still working' }));
391
+ p.scheduleSave();
392
+ await tick();
393
+ await tick();
394
+ assert.is(f.saved.length, 1, 'unmoved boundary does not re-write');
395
+
396
+ // The old `interrupted` latch made that state permanent. A new prompt must still land.
397
+ history.push(msg({ role: 'user', content: 'next thing' }));
398
+ p.scheduleSave();
399
+ await tick();
400
+ await tick();
401
+ assert.is(f.saved.length, 2, 'a moved boundary writes again — the skip is not a latch');
402
+ assert.is(f.saved[1].messages.length, 4, 'frozen through the new user message');
319
403
  });
320
404
 
321
405
  Suite('clearRestoreCore() drops the snapshot', async () => {
@@ -515,4 +599,189 @@ Suite(
515
599
  },
516
600
  );
517
601
 
602
+ // GENC-1511: the save telemetry has to describe what HAPPENED, not what was attempted.
603
+ // `persistence.saved` used to be emitted before `provider.save` was awaited and the
604
+ // rejection was swallowed into `logger.error`, so a session whose writes had been
605
+ // failing for hours reported nothing but successful saves — in the debug log, in Loki
606
+ // and in Sentry alike.
607
+ Suite('a save that lands emits persistence.saved and no failure event', async () => {
608
+ const { p, f } = makePersister({ history: [msg({ content: 'hi' })] });
609
+ p.scheduleSave();
610
+ await tick();
611
+ assert.is(f.saved.length, 1, 'the write landed');
612
+ const types = getMetaEvents(KEY).map((e) => e.type);
613
+ assert.ok(types.includes('persistence.saved'), 'success is recorded');
614
+ assert.not.ok(types.includes('persistence.save-failed'), 'no failure recorded');
615
+ });
616
+
617
+ Suite('a save that throws emits persistence.save-failed and NOT persistence.saved', async () => {
618
+ const { p, f } = makePersister({
619
+ history: [msg({ content: 'hi' })],
620
+ saveError: Object.assign(new TypeError('Converting circular structure to JSON'), {}),
621
+ });
622
+ p.scheduleSave();
623
+ await tick();
624
+ assert.is(f.saved.length, 0, 'nothing was written');
625
+ const events = getMetaEvents(KEY);
626
+ const types = events.map((e) => e.type);
627
+ assert.not.ok(
628
+ types.includes('persistence.saved'),
629
+ 'a failed write must never report itself as saved',
630
+ );
631
+ const failed = events.find((e) => e.type === 'persistence.save-failed');
632
+ assert.ok(failed, 'the failure is recorded');
633
+ // `name` is what separates a transport rejection from a serialisation fault: a
634
+ // TypeError means the body never reached the wire, so there is no server-side trace.
635
+ assert.is((failed?.detail as Record<string, unknown>)?.error, 'TypeError');
636
+ assert.is((failed?.detail as Record<string, unknown>)?.messages, 1);
637
+ });
638
+
639
+ // GENC-1511 — the user-facing warning. The gate is `resumable`, not `busy`: a bootstrap journey
640
+ // is ONE turn (measured at 2817 s with 82 widget interactions inside it), so a turn-boundary gate
641
+ // would hold the warning for 47 minutes.
642
+ Suite('a single failed save does NOT report broken — one blip is not an outage', async () => {
643
+ const { p, f } = makePersister({
644
+ history: [msg({ content: 'hi' })],
645
+ saveError: new Error('transient'),
646
+ });
647
+ p.scheduleSave();
648
+ await tick();
649
+ assert.is(f.brokenReports.length, 0, 'a modal about a self-healing blip trains dismissal');
650
+ });
651
+
652
+ Suite('two consecutive failures report broken when the agent is resumable', async () => {
653
+ const history = [msg({ content: 'one' })];
654
+ const { p, f } = makePersister({ history, saveError: new Error('boom') });
655
+ p.scheduleSave();
656
+ await tick();
657
+ history.push(msg({ content: 'two' }));
658
+ p.scheduleSave();
659
+ await tick();
660
+ assert.is(f.brokenReports.length, 1, 'reported once, at the second failure');
661
+ assert.is(f.brokenReports[0].reason, 'boom');
662
+ assert.is(f.brokenReports[0].unsavedMessages, 2, 'counts what the store does not have');
663
+
664
+ // ...and only once per episode, however many more saves fail.
665
+ history.push(msg({ content: 'three' }));
666
+ p.scheduleSave();
667
+ await tick();
668
+ assert.is(f.brokenReports.length, 1, 'not re-reported while still broken');
669
+ });
670
+
671
+ Suite('a NON-resumable agent holds the report back until it is safe to reload', async () => {
672
+ const history = [msg({ content: 'one' }), msg({ content: 'two' })];
673
+ const agent = {
674
+ name: 'UI Builder',
675
+ resumable: async () => false,
676
+ serialize: () => ({}),
677
+ };
678
+ const { p, f } = makePersister({ history, activeAgent: agent, saveError: new Error('boom') });
679
+ // Unrolled deliberately: these saves must happen in ORDER, so the lint rule's suggested
680
+ // Promise.all would change what is being tested.
681
+ history.push(msg({ role: 'assistant', content: 'turn 0' }));
682
+ p.scheduleSave();
683
+ await tick();
684
+ history.push(msg({ role: 'assistant', content: 'turn 1' }));
685
+ p.scheduleSave();
686
+ await tick();
687
+ history.push(msg({ role: 'assistant', content: 'turn 2' }));
688
+ p.scheduleSave();
689
+ await tick();
690
+ assert.is(f.brokenReports.length, 0, 'advising a reload mid-flow would discard the VFS buffer');
691
+
692
+ // Back at rest: now a reload is safe, so now the user is told.
693
+ agent.resumable = async () => true;
694
+ history.push(msg({ content: 'next' }));
695
+ p.scheduleSave();
696
+ await tick();
697
+ assert.is(f.brokenReports.length, 1, 'reported at the first resumable moment');
698
+ });
699
+
700
+ Suite('a save that lands withdraws the warning', async () => {
701
+ const history = [msg({ content: 'one' })];
702
+ const fakes = { history, saveError: new Error('boom') as Error | undefined };
703
+ const { p, f } = makePersister(fakes);
704
+ p.scheduleSave();
705
+ await tick();
706
+ history.push(msg({ content: 'two' }));
707
+ p.scheduleSave();
708
+ await tick();
709
+ assert.is(f.brokenReports.length, 1, 'broken');
710
+
711
+ f.saveError = undefined; // the store comes back — observed twice in the wild
712
+ history.push(msg({ content: 'three' }));
713
+ p.scheduleSave();
714
+ await tick();
715
+ assert.is(f.recovered, 1, 'the warning is withdrawn rather than left stale');
716
+ assert.ok(f.saved.length > 0, 'and the write landed');
717
+ });
718
+
719
+ Suite('a failed FREEZE write is reported, not swallowed', async () => {
720
+ // This catch was the one silent failure left after save-failed was added to the resumable
721
+ // branch: a session breaking while an agent was mid-flow reported nothing at all.
722
+ const history = [msg({ content: 'do it' }), msg({ role: 'assistant', content: 'working' })];
723
+ const { p, f } = makePersister({
724
+ history,
725
+ activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
726
+ saveError: new Error('freeze write failed'),
727
+ });
728
+ p.scheduleSave();
729
+ await tick();
730
+ await tick();
731
+ const failed = getMetaEvents(KEY).filter((e) => e.type === 'persistence.save-failed');
732
+ assert.is(failed.length, 1, 'the freeze path now emits the failure event');
733
+ assert.is(
734
+ (failed[0].detail as Record<string, unknown>)?.frozen,
735
+ true,
736
+ 'marked as the freeze path',
737
+ );
738
+ });
739
+
740
+ // GENC-1511 review [P1]: the failure event must stay serialisable even when the thing that broke
741
+ // the save is the agent snapshot itself. `recordMetaEvent` stores detail BY REFERENCE, so
742
+ // spreading the captured snapshot made the high-importance event circular too — and then
743
+ // `assembleDebugLog` threw while stringifying it, so `downloadDebugLog` could not produce the log
744
+ // the blocking modal tells the user to save. Reproduced by Matt; regression-guarded here.
745
+ Suite('the save-failed event stays serialisable when the agent snapshot is circular', async () => {
746
+ const circular: Record<string, unknown> = { machine: 'generating' };
747
+ circular.self = circular;
748
+ const { p } = makePersister({
749
+ history: [msg({ content: 'hi' })],
750
+ activeAgent: { name: 'UI Builder', serialize: () => circular },
751
+ saveError: new TypeError('Converting circular structure to JSON'),
752
+ });
753
+ p.scheduleSave();
754
+ await tick();
755
+ const events = getMetaEvents(KEY);
756
+ const failed = events.find((e) => e.type === 'persistence.save-failed');
757
+ assert.ok(failed, 'the failure is recorded');
758
+ assert.not.ok(
759
+ (failed?.detail as Record<string, unknown>)?.agentSnapshots,
760
+ 'the raw snapshot must NOT be in the failure detail',
761
+ );
762
+ // The whole point: the debug log must still be producible.
763
+ JSON.stringify(events);
764
+ });
765
+
766
+ Suite('a non-resumable agent is NEVER told to reload, however long it stays broken', async () => {
767
+ // GENC-1511 review [P1]: an earlier draft bypassed the gate after 90 s. The modal's only action
768
+ // is a reload, so firing it here forces the user to discard the out-of-band in-flight work the
769
+ // gate exists to protect.
770
+ const history = [msg({ content: 'do it' })];
771
+ const { p, f } = makePersister({
772
+ history,
773
+ activeAgent: { name: 'UI Builder', resumable: async () => false, serialize: () => ({}) },
774
+ saveError: new Error('boom'),
775
+ });
776
+ history.push(msg({ role: 'assistant', content: 'a' }));
777
+ p.scheduleSave();
778
+ await tick();
779
+ await tick();
780
+ history.push(msg({ role: 'assistant', content: 'b' }));
781
+ p.scheduleSave();
782
+ await tick();
783
+ await tick();
784
+ assert.is(f.brokenReports.length, 0, 'no unsafe reload prompt, no matter how many failures');
785
+ });
786
+
518
787
  Suite.run();