@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
@@ -48,6 +48,14 @@ const DEFAULT_DIAGNOSTICS_DEBOUNCE_MS = 4_000;
48
48
  * (GENC-1351 §5.9) and set no `notResumableMessage`. Paired with the composer
49
49
  * pre-filled with the flow-activating prompt, so "send it again" is one keystroke.
50
50
  */
51
+ /**
52
+ * Consecutive failed saves before persistence is declared broken (GENC-1511). Two, not one: a
53
+ * single failure is usually a transient blip that the next autosave clears on its own, and a
54
+ * modal about a problem that has already fixed itself trains people to dismiss the one that
55
+ * matters. At the 1 s autosave debounce this is ~2 s of exposure.
56
+ */
57
+ const SAVE_FAILURES_BEFORE_BROKEN = 2;
58
+
51
59
  const DEFAULT_NOT_RESUMABLE_MESSAGE =
52
60
  "Sorry, I didn't manage to finish the task from your previous message. Send it again if you'd like me to try it again";
53
61
 
@@ -76,6 +84,22 @@ export interface SessionPersisterDeps {
76
84
  * events, etc. — and is diffed against the shared cursors here (GENC-1351 §5.10).
77
85
  */
78
86
  getDiagnosticEntries: () => DiagnosticEntry[];
87
+ /**
88
+ * Persistence has failed repeatedly and it is now a safe moment to tell the user (GENC-1511).
89
+ * Called at most once per broken episode — see {@link SessionPersister.persistSession} for the
90
+ * gating, which waits for the active agent to be RESUMABLE so we never advise a reload at a
91
+ * moment when reloading would throw away in-flight work the snapshot cannot carry.
92
+ */
93
+ onPersistenceBroken?: (detail: {
94
+ /** Messages in the live transcript that the store does not have. */
95
+ unsavedMessages: number;
96
+ /** `savedAt` of the last snapshot that landed, if one ever did. */
97
+ lastSavedAt?: string;
98
+ /** Why the writes are failing, as reported by the provider. */
99
+ reason: string;
100
+ }) => void;
101
+ /** A save landed after a broken episode — the warning can be withdrawn (GENC-1511). */
102
+ onPersistenceRecovered?: () => void;
79
103
  }
80
104
 
81
105
  /**
@@ -104,6 +128,30 @@ export class SessionPersister {
104
128
  * empty). Seeded on restore. See {@link reconcileAgentSnapshots}.
105
129
  */
106
130
  private _cache: Record<string, unknown> = {};
131
+ /**
132
+ * How many messages the last SUCCESSFUL write persisted. Two jobs, both GENC-1511:
133
+ * it is the monotonic floor for a freeze boundary (the stored transcript must never
134
+ * shrink), and it is what lets a running flow skip a re-write when its boundary has
135
+ * not moved. Content-keyed, so unlike the `interrupted` latch it replaces it can never
136
+ * become a one-way "never write again" state.
137
+ */
138
+ private _persistedMessages = 0;
139
+ /**
140
+ * Whether the CURRENT non-resumable episode has already stamped its `interrupted`
141
+ * marker. Reset by every resumable save, so each new episode stamps exactly once and
142
+ * the "boundary unmoved" skip below can never swallow the stamp itself (GENC-1511).
143
+ */
144
+ private _interruptedStamped = false;
145
+ /** Consecutive failed saves; reset by any success. GENC-1511. */
146
+ private _saveFailures = 0;
147
+ /** When the failure count crossed {@link SAVE_FAILURES_BEFORE_BROKEN}; `undefined` = healthy. */
148
+ private _brokenSince?: number;
149
+ /** Whether this broken episode has already been reported to the host. */
150
+ private _brokenReported = false;
151
+ /** `savedAt` of the last snapshot that actually landed, for the host's message. */
152
+ private _lastSavedAt?: string;
153
+ /** Provider's reason for the most recent failure, for the host's message. */
154
+ private _lastFailureReason = '';
107
155
  /**
108
156
  * The loaded preferences blob (GENC-1351 §5.10 Phase 2), owned here so it's
109
157
  * **shared per session** — the element's `applyPreferenceBaseline` reconciler reads
@@ -186,6 +234,92 @@ export class SessionPersister {
186
234
  }
187
235
  }
188
236
 
237
+ /**
238
+ * Record a save outcome and, when persistence has been failing, decide whether now is the
239
+ * moment to tell the user (GENC-1511).
240
+ *
241
+ * The gate is `resumable`, NOT "is a turn running". `busy` was the obvious choice and it is
242
+ * useless here: a bootstrap journey is ONE turn — measured at 2817 s containing 82 widget
243
+ * interactions — so waiting for a turn boundary would hold the warning for 47 minutes. What
244
+ * actually matters is whether a reload can be resumed from the stored snapshot, and that is
245
+ * exactly what `resumable` already answers for the freeze. Bootstrap keeps everything in its
246
+ * machine context, so it is always resumable and gets told immediately; UI Builder keeps
247
+ * in-flight work in an out-of-band VFS buffer, so it waits for `idle`/`done` — which is also
248
+ * the point at which its files have been flushed and a reload costs nothing.
249
+ */
250
+ private recordSaveOutcome(ok: boolean, reason: string, savedAt?: string): void {
251
+ if (ok) {
252
+ const wasBroken = this._brokenSince !== undefined;
253
+ this._saveFailures = 0;
254
+ this._brokenSince = undefined;
255
+ this._lastSavedAt = savedAt ?? this._lastSavedAt;
256
+ if (wasBroken) {
257
+ // Withdraw the warning: a save landing means the store is caught up, which we have
258
+ // watched happen twice in the wild — a broken window is not necessarily permanent.
259
+ this._brokenReported = false;
260
+ this.deps.onPersistenceRecovered?.();
261
+ }
262
+ return;
263
+ }
264
+ this._saveFailures += 1;
265
+ this._lastFailureReason = reason;
266
+ if (this._saveFailures >= SAVE_FAILURES_BEFORE_BROKEN && this._brokenSince === undefined) {
267
+ this._brokenSince = Date.now();
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Surface a broken episode if it is now safe to advise a reload.
273
+ *
274
+ * Gated on `resumable` and NOTHING else. An earlier draft added a 90 s backstop so a flow that
275
+ * errored and stuck mid-state could not hold the warning forever — that was wrong, and the
276
+ * review caught it: the modal's only action is a reload, so firing it while the agent is
277
+ * non-resumable forces the user to destroy exactly the out-of-band, in-flight work the gate
278
+ * exists to protect. Being told late is bad; being told to do the damaging thing is worse.
279
+ * Surfacing a stuck flow earlier needs non-blocking warning UI, not this modal.
280
+ */
281
+ private maybeReportBroken(resumable: boolean, liveMessages: number): void {
282
+ if (this._brokenSince === undefined || this._brokenReported || !resumable) return;
283
+ this._brokenReported = true;
284
+ this.deps.onPersistenceBroken?.({
285
+ unsavedMessages: Math.max(0, liveMessages - this._persistedMessages),
286
+ lastSavedAt: this._lastSavedAt,
287
+ reason: this._lastFailureReason,
288
+ });
289
+ }
290
+
291
+ /**
292
+ * Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the flow-owner
293
+ * during a locked flow, like `serialize`). A thrown predicate fails safe to persisting; no
294
+ * live active agent (e.g. the post-restore, pre-reactivation save) leaves it resumable and
295
+ * persists normally.
296
+ *
297
+ * Extracted from `persistSession` so that method stays under the complexity cap, and because
298
+ * this answer now has two consumers: the freeze decision, and whether it is safe to advise the
299
+ * user to reload (GENC-1511).
300
+ */
301
+ private async resolveResumable(
302
+ liveActive: ReturnType<NonNullable<AiDriver['getActiveAgent']>> | undefined,
303
+ key: string,
304
+ agentSnapshots: Record<string, unknown>,
305
+ messages: ChatMessage[],
306
+ s: SessionStoreReturn['store']['aiAssistant'],
307
+ ): Promise<boolean> {
308
+ if (!liveActive?.resumable) return true;
309
+ try {
310
+ return await liveActive.resumable({
311
+ agentName: liveActive.name,
312
+ sessionKey: key,
313
+ state: agentSnapshots[liveActive.name],
314
+ messages,
315
+ isFlowOwner: s.flowOwnerAgentName === liveActive.name,
316
+ });
317
+ } catch (e) {
318
+ logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
319
+ return true;
320
+ }
321
+ }
322
+
189
323
  /**
190
324
  * Capture the current session to durable storage (GENC-1351 §5.1). Reads the
191
325
  * full transcript from the driver and the restorable scalars from the store, and
@@ -231,64 +365,69 @@ export class SessionPersister {
231
365
  );
232
366
  this._cache = cache;
233
367
 
234
- // Non-resumable-workflow gate (GENC-1351 §5.9): consult the active agent (the
235
- // flow-owner during a locked flow, like `serialize` above). A thrown predicate
236
- // fails safe to persisting; no live active agent (e.g. the post-restore,
237
- // pre-reactivation save) leaves it resumable and persists normally.
238
- let resumable = true;
239
- if (liveActive?.resumable) {
240
- try {
241
- resumable = await liveActive.resumable({
242
- agentName: liveActive.name,
243
- sessionKey: key,
244
- state: agentSnapshots[liveActive.name],
245
- messages,
246
- isFlowOwner: s.flowOwnerAgentName === liveActive.name,
247
- });
248
- } catch (e) {
249
- logger.error('Session save: resumable() threw — persisting (fail-safe):', e);
250
- resumable = true;
251
- }
252
- }
368
+ const resumable = await this.resolveResumable(liveActive, key, agentSnapshots, messages, s);
369
+
370
+ // Report a broken episode at the first SAFE moment, which is what `resumable` decides.
371
+ // Deliberately before either branch acts, so the warning does not wait on the very write
372
+ // that is failing.
373
+ this.maybeReportBroken(resumable, messages.length);
253
374
 
254
375
  if (!resumable) {
255
- // FREEZE while the flow runs: stop advancing the stored snapshot so the
256
- // in-flight agent's work is never persisted and the *previous* conversation is
257
- // kept intact ("infinite debounce while running"). The freeze target is the
258
- // **currently-persisted** snapshot — the shared source of truth — NOT any
259
- // per-view memory. Stamp it once with an `interrupted` marker so restore shows
260
- // an assistant "start again" message; once stamped, subsequent saves no-op.
261
- const existing = await provider.load(key).catch(() => null);
262
- if (existing?.interrupted) return; // already frozen + stamped by some instance
376
+ // FREEZE THE FLOW STATE, NOT THE TRANSCRIPT (GENC-1511).
377
+ //
378
+ // The intent of §5.9 is the one stated on `PersistedSession.interrupted`: the
379
+ // conversation is persisted and restored in full, and only the running agent's
380
+ // flow-state is dropped. The original implementation did the opposite whenever a
381
+ // snapshot already existed — it froze at the **stored** blob (`existing ?? …`),
382
+ // so everything since the last resumable save was silently rolled back, transcript
383
+ // included, and `if (existing?.interrupted) return` then latched that rollback in
384
+ // for the life of the page. A UI-builder session spent 3h17m in that state: 256
385
+ // loads, zero writes, and a reload that took the user back to a mid-bootstrap
386
+ // snapshot from three hours earlier.
387
+ //
388
+ // So the freeze target is now ALWAYS the live transcript through the last user
389
+ // message — the conversation plus the prompt that started the flow, with the
390
+ // agent's in-flight turns and flow-state dropped. Advancing on each new boundary
391
+ // means a long-running flow can no longer cost the user the work that preceded it.
263
392
  const { resumeMessage, resumePrompt } = resolveNonResumableNotice({
264
393
  notResumableMessage: liveActive?.notResumableMessage,
265
394
  flowActivationPrompt: s.flowActivationPrompt,
266
395
  messages,
267
396
  defaultMessage: DEFAULT_NOT_RESUMABLE_MESSAGE,
268
397
  });
269
- // Freeze at the persisted snapshot (the last resumable state). Fallback when
270
- // nothing is stored yet — a genuinely fresh session that goes non-resumable on
271
- // its first turn, OR a load that failed (a corrupt read, a transient network
272
- // error): freeze at the current transcript **through the last user message**
273
- // (flow-state stripped), so we keep the conversation + the prompt that started
274
- // the flow but drop the agent's in-flight turns. NEVER an empty snapshot —
275
- // losing the conversation is the one outcome we must avoid.
276
- const frozen =
277
- existing ??
278
- createPersistedSession({
279
- sessionKey: key,
280
- host: window.location.host,
281
- messages: keepThroughLastUserMessage(messages),
282
- activeAgentName: s.activeAgent?.name,
283
- pinnedAgentName: s.pinnedAgentName,
284
- flowOwnerAgentName: null,
285
- sessionCostUsd: s.sessionUsage.costUsd,
286
- contextTokens: s.contextTokens,
287
- contextLimit: s.contextLimit,
288
- activeModel: s.activeModel,
289
- activeProviderName: s.activeProviderName,
290
- agentSnapshots: {},
291
- });
398
+ // Never regress the stored transcript. `keepThroughLastUserMessage` alone can be
399
+ // SHORTER than what is already persisted — an agent that goes non-resumable without
400
+ // a fresh user prompt (auto-advance, a resumed journey step) would otherwise drop
401
+ // completed exchanges that were already safely stored. Taking the max of the two
402
+ // makes the boundary monotonic: it advances with each new prompt and never rewinds.
403
+ const boundary = Math.max(
404
+ keepThroughLastUserMessage(messages).length,
405
+ this._persistedMessages,
406
+ );
407
+ const frozen = createPersistedSession({
408
+ sessionKey: key,
409
+ host: window.location.host,
410
+ messages: messages.slice(0, Math.min(boundary, messages.length)),
411
+ activeAgentName: s.activeAgent?.name,
412
+ pinnedAgentName: s.pinnedAgentName,
413
+ flowOwnerAgentName: null,
414
+ sessionCostUsd: s.sessionUsage.costUsd,
415
+ contextTokens: s.contextTokens,
416
+ contextLimit: s.contextLimit,
417
+ activeModel: s.activeModel,
418
+ activeProviderName: s.activeProviderName,
419
+ agentSnapshots: {},
420
+ });
421
+ // Skip the write when the frozen boundary has not moved — a flow autosaves on every
422
+ // transcript change, and while it runs the last user message (and therefore the
423
+ // frozen content) is unchanged, so re-writing it would put one ~MB PUT on the wire
424
+ // per tool call for nothing. Keyed on CONTENT, unlike the old `interrupted` latch:
425
+ // it re-arms the moment the boundary moves, and the resumable path below clears it,
426
+ // so it can never become the one-way "never write again" state it replaces. It also
427
+ // drops the `provider.load` this branch used to issue on every single autosave.
428
+ if (this._persistedMessages === frozen.messages.length && this._interruptedStamped) {
429
+ return;
430
+ }
292
431
  this.logMeta('persistence.suppressed', {
293
432
  agent: liveActive?.name ?? null,
294
433
  isFlowOwner: s.flowOwnerAgentName === liveActive?.name,
@@ -300,8 +439,34 @@ export class SessionPersister {
300
439
  ...frozen,
301
440
  interrupted: { message: resumeMessage, prompt: resumePrompt },
302
441
  });
442
+ this._persistedMessages = frozen.messages.length;
443
+ this._interruptedStamped = true;
444
+ this.recordSaveOutcome(true, '', frozen.savedAt);
303
445
  logger.debug('Flow non-resumable — froze snapshot + stamped interrupted marker', { key });
304
446
  } catch (e) {
447
+ // GENC-1511: this catch used to be the ONE swallowed failure left after the save-failed
448
+ // event was added to the resumable branch — so a session that broke while an agent was
449
+ // mid-flow still reported nothing at all, which is precisely the blind spot the
450
+ // user-facing warning exists to cover.
451
+ this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
452
+ // Deliberately NOT reporting from here. This is the non-resumable branch and a reload is
453
+ // the modal's only action, so advising one now would destroy the in-flight work the
454
+ // snapshot could not carry. `recordSaveOutcome` has already set `_brokenSince`, so the
455
+ // next `persistSession` whose `resolveResumable()` returns true reports it — late, but at
456
+ // a point where the advice is safe to take.
457
+ //
458
+ // (An earlier version called `maybeReportBroken(false, …)` here. Review caught that it was
459
+ // an unconditional no-op — that method returns immediately on `!resumable` — and that its
460
+ // comment pointed at a 90 s backstop which had itself been removed earlier in review.)
461
+ this.logMeta('persistence.save-failed', {
462
+ messages: frozen.messages.length,
463
+ agents: [],
464
+ active: liveActive?.name ?? null,
465
+ hasSerialize: !!liveActive?.serialize,
466
+ frozen: true,
467
+ error: (e as Error)?.name ?? typeof e,
468
+ message: (e as Error)?.message ?? String(e),
469
+ });
305
470
  logger.error('Session save (interrupted marker) failed:', e);
306
471
  }
307
472
  return;
@@ -326,17 +491,55 @@ export class SessionPersister {
326
491
  // Observability (GENC-1351): record what each autosave captured for stateful
327
492
  // agents — `agents: []` here means no machine snapshot was taken (the bug to
328
493
  // watch for: the active agent's `serialize()` returned nothing).
329
- this.logMeta('persistence.saved', {
494
+ //
495
+ // GENC-1511: this event is emitted AFTER the write resolves, never before. It used
496
+ // to be logged ahead of `provider.save`, which made it a record of an ATTEMPT while
497
+ // reading as a record of a SAVE — and the failure below was swallowed into
498
+ // `logger.error`, which is not wired to Sentry. A session whose writes had been
499
+ // failing for three hours therefore showed 52 cheerful `persistence.saved` events
500
+ // and no failures anywhere: not in the debug log, not in Loki, not in Sentry. The
501
+ // whole incident was invisible until the box's nginx access log was read by hand.
502
+ const captured = {
330
503
  messages: messages.length,
331
504
  agents: Object.keys(agentSnapshots),
332
505
  active: liveActive?.name ?? null,
333
506
  hasSerialize: !!liveActive?.serialize,
334
507
  agentSnapshots,
335
- });
508
+ };
336
509
  try {
337
510
  await provider.save(key, snapshot);
511
+ this._persistedMessages = messages.length;
512
+ this._interruptedStamped = false;
513
+ this.recordSaveOutcome(true, '', snapshot.savedAt);
514
+ this.logMeta('persistence.saved', captured);
338
515
  logger.debug('Session saved', { key, messages: messages.length });
339
516
  } catch (e) {
517
+ // `high` importance, and deliberately verbose: this is the one event that says
518
+ // "the transcript on screen is no longer backed by the store". `name` separates a
519
+ // provider/transport rejection from a serialisation fault — a `TypeError` here
520
+ // means the body never reached the wire at all, because a provider builds its
521
+ // request body inside the `fetch` init, so a throw while serialising creates no
522
+ // request and leaves no server-side trace.
523
+ this.recordSaveOutcome(false, (e as Error)?.message ?? String(e));
524
+ // Report straight away rather than waiting for the next attempt: we are on the resumable
525
+ // path, so a reload is safe NOW, and a one-cycle delay is a second of extra exposure for
526
+ // no benefit.
527
+ this.maybeReportBroken(true, messages.length);
528
+ // Deliberately NOT `...captured`: that carries the raw `agentSnapshots` object, and
529
+ // `recordMetaEvent` stores the detail BY REFERENCE. When the save failed *because* that
530
+ // object is circular — the fault this catch exists for — the high-importance failure event
531
+ // became circular too, so `assembleDebugLog` threw while stringifying it and
532
+ // `downloadDebugLog` could not produce the log this modal tells the user to save. Names
533
+ // and scalars only; the success event above may keep the snapshots, because a landed write
534
+ // has already proved them serialisable.
535
+ this.logMeta('persistence.save-failed', {
536
+ messages: captured.messages,
537
+ agents: captured.agents,
538
+ active: captured.active,
539
+ hasSerialize: captured.hasSerialize,
540
+ error: (e as Error)?.name ?? typeof e,
541
+ message: (e as Error)?.message ?? String(e),
542
+ });
340
543
  logger.error('Session save failed:', e);
341
544
  }
342
545
  }
@@ -405,6 +608,9 @@ export class SessionPersister {
405
608
  // by the history change below, before any agent re-activates) preserves the
406
609
  // machine snapshot instead of overwriting it with an empty one (GENC-1351).
407
610
  this._cache = { ...snapshot.agentSnapshots };
611
+ // Seed the monotonic freeze floor too (GENC-1511): a flow that goes non-resumable
612
+ // straight after a restore must not freeze BELOW what the restore just brought back.
613
+ this._persistedMessages = trimmed.length;
408
614
  // Observability (GENC-1351): record exactly what the restore primed, so a
409
615
  // post-reload debug-log export shows whether a stateful agent's snapshot
410
616
  // actually came back (meta events reset on reload, so this is the only window
@@ -537,6 +743,8 @@ export class SessionPersister {
537
743
  */
538
744
  async clearRestoreCore(): Promise<void> {
539
745
  this._cache = {};
746
+ this._persistedMessages = 0;
747
+ this._interruptedStamped = false;
540
748
  const { provider } = this.deps.getPersistence();
541
749
  const key = this.stateKey;
542
750
  if (!provider) return;