@genesislcap/ai-assistant 15.19.0 → 15.19.1
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.
- package/package.json +17 -18
- package/api-extractor.json +0 -11
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.compact.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.invocation-scope.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.thinking-policy.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.trace-capture.test.d.ts.map +0 -1
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts +0 -2
- package/dist/dts/components/chat-driver/chat-driver.turn-usage.test.d.ts.map +0 -1
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts +0 -2
- package/dist/dts/components/chat-interaction-wrapper/chat-interaction-wrapper.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.budget.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.cost.test.d.ts.map +0 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts +0 -2
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.pin.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.styles.test.d.ts.map +0 -1
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts +0 -2
- package/dist/dts/components/settings-modal/settings-modal.template.test.d.ts.map +0 -1
- package/dist/dts/config/define-stateful-agent.test.d.ts +0 -2
- package/dist/dts/config/define-stateful-agent.test.d.ts.map +0 -1
- package/dist/dts/config/validate-providers.test.d.ts +0 -2
- package/dist/dts/config/validate-providers.test.d.ts.map +0 -1
- package/dist/dts/main/blocked-state.test.d.ts +0 -2
- package/dist/dts/main/blocked-state.test.d.ts.map +0 -1
- package/dist/dts/main/budget-meter.test.d.ts +0 -2
- package/dist/dts/main/budget-meter.test.d.ts.map +0 -1
- package/dist/dts/main/cost-session-banking.test.d.ts +0 -2
- package/dist/dts/main/cost-session-banking.test.d.ts.map +0 -1
- package/dist/dts/main/file-attachments.test.d.ts +0 -2
- package/dist/dts/main/file-attachments.test.d.ts.map +0 -1
- package/dist/dts/main/interaction-cost.test.d.ts +0 -2
- package/dist/dts/main/interaction-cost.test.d.ts.map +0 -1
- package/dist/dts/main/main.styles.test.d.ts +0 -2
- package/dist/dts/main/main.styles.test.d.ts.map +0 -1
- package/dist/dts/main/persistence-broken-sources.test.d.ts +0 -2
- package/dist/dts/main/persistence-broken-sources.test.d.ts.map +0 -1
- package/dist/dts/main/popout-interaction-gate.test.d.ts +0 -2
- package/dist/dts/main/popout-interaction-gate.test.d.ts.map +0 -1
- package/dist/dts/state/ai-assistant-slice.test.d.ts +0 -2
- package/dist/dts/state/ai-assistant-slice.test.d.ts.map +0 -1
- package/dist/dts/state/debug-event-log.test.d.ts +0 -2
- package/dist/dts/state/debug-event-log.test.d.ts.map +0 -1
- package/dist/dts/state/driver-registry.test.d.ts +0 -2
- package/dist/dts/state/driver-registry.test.d.ts.map +0 -1
- package/dist/dts/state/interaction-context.test.d.ts +0 -2
- package/dist/dts/state/interaction-context.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics-cursors.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/diagnostics.test.d.ts +0 -2
- package/dist/dts/state/persistence/diagnostics.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence-provider.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persistence.integration.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-persister.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-persister.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/session-snapshot.test.d.ts +0 -2
- package/dist/dts/state/persistence/session-snapshot.test.d.ts.map +0 -1
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts +0 -2
- package/dist/dts/state/persistence/stateful-restore.e2e.test.d.ts.map +0 -1
- package/dist/dts/utils/animation-exclusivity.test.d.ts +0 -2
- package/dist/dts/utils/animation-exclusivity.test.d.ts.map +0 -1
- package/dist/dts/utils/collect-session-models.test.d.ts +0 -2
- package/dist/dts/utils/collect-session-models.test.d.ts.map +0 -1
- package/dist/dts/utils/condense-history.test.d.ts +0 -2
- package/dist/dts/utils/condense-history.test.d.ts.map +0 -1
- package/dist/dts/utils/cost-session-history.test.d.ts +0 -2
- package/dist/dts/utils/cost-session-history.test.d.ts.map +0 -1
- package/dist/dts/utils/derive-cost-session-title.test.d.ts +0 -2
- package/dist/dts/utils/derive-cost-session-title.test.d.ts.map +0 -1
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts +0 -2
- package/dist/dts/utils/flatten-sub-agent-messages.test.d.ts.map +0 -1
- package/dist/dts/utils/history-transform.test.d.ts +0 -2
- package/dist/dts/utils/history-transform.test.d.ts.map +0 -1
- package/dist/dts/utils/message-partition.test.d.ts +0 -2
- package/dist/dts/utils/message-partition.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts +0 -2
- package/dist/dts/utils/resolve-cost-history-config.test.d.ts.map +0 -1
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts +0 -2
- package/dist/dts/utils/resolve-preference-baseline.test.d.ts.map +0 -1
- package/dist/dts/utils/strip-agent-handlers.test.d.ts +0 -2
- package/dist/dts/utils/strip-agent-handlers.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-costs.test.d.ts +0 -2
- package/dist/dts/utils/sum-costs.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-tokens.test.d.ts +0 -2
- package/dist/dts/utils/sum-tokens.test.d.ts.map +0 -1
- package/dist/dts/utils/sum-usage.test.d.ts +0 -2
- package/dist/dts/utils/sum-usage.test.d.ts.map +0 -1
- package/dist/dts/utils/usage-rows.test.d.ts +0 -2
- package/dist/dts/utils/usage-rows.test.d.ts.map +0 -1
- package/dist/esm/components/chat-driver/chat-driver.compact.test.js +0 -93
- package/dist/esm/components/chat-driver/chat-driver.invocation-scope.test.js +0 -238
- package/dist/esm/components/chat-driver/chat-driver.test.js +0 -2770
- package/dist/esm/components/chat-driver/chat-driver.thinking-policy.test.js +0 -137
- package/dist/esm/components/chat-driver/chat-driver.trace-capture.test.js +0 -200
- package/dist/esm/components/chat-driver/chat-driver.turn-usage.test.js +0 -268
- package/dist/esm/components/chat-interaction-wrapper/chat-interaction-wrapper.test.js +0 -104
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.budget.test.js +0 -312
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.cost.test.js +0 -76
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.pin.test.js +0 -61
- package/dist/esm/components/settings-modal/settings-modal.styles.test.js +0 -80
- package/dist/esm/components/settings-modal/settings-modal.template.test.js +0 -91
- package/dist/esm/config/define-stateful-agent.test.js +0 -53
- package/dist/esm/config/validate-providers.test.js +0 -100
- package/dist/esm/main/blocked-state.test.js +0 -1065
- package/dist/esm/main/budget-meter.test.js +0 -359
- package/dist/esm/main/cost-session-banking.test.js +0 -538
- package/dist/esm/main/file-attachments.test.js +0 -164
- package/dist/esm/main/interaction-cost.test.js +0 -107
- package/dist/esm/main/main.styles.test.js +0 -161
- package/dist/esm/main/persistence-broken-sources.test.js +0 -180
- package/dist/esm/main/popout-interaction-gate.test.js +0 -54
- package/dist/esm/state/ai-assistant-slice.test.js +0 -229
- package/dist/esm/state/debug-event-log.test.js +0 -117
- package/dist/esm/state/driver-registry.test.js +0 -55
- package/dist/esm/state/interaction-context.test.js +0 -78
- package/dist/esm/state/persistence/diagnostics-cursors.test.js +0 -30
- package/dist/esm/state/persistence/diagnostics.test.js +0 -280
- package/dist/esm/state/persistence/session-persistence-provider.test.js +0 -110
- package/dist/esm/state/persistence/session-persistence.integration.test.js +0 -113
- package/dist/esm/state/persistence/session-persister.test.js +0 -613
- package/dist/esm/state/persistence/session-snapshot.test.js +0 -256
- package/dist/esm/state/persistence/stateful-restore.e2e.test.js +0 -158
- package/dist/esm/utils/animation-exclusivity.test.js +0 -52
- package/dist/esm/utils/collect-session-models.test.js +0 -53
- package/dist/esm/utils/condense-history.test.js +0 -547
- package/dist/esm/utils/cost-session-history.test.js +0 -174
- package/dist/esm/utils/derive-cost-session-title.test.js +0 -37
- package/dist/esm/utils/flatten-sub-agent-messages.test.js +0 -139
- package/dist/esm/utils/history-transform.test.js +0 -158
- package/dist/esm/utils/message-partition.test.js +0 -90
- package/dist/esm/utils/resolve-cost-history-config.test.js +0 -32
- package/dist/esm/utils/resolve-preference-baseline.test.js +0 -69
- package/dist/esm/utils/strip-agent-handlers.test.js +0 -81
- package/dist/esm/utils/sum-costs.test.js +0 -114
- package/dist/esm/utils/sum-tokens.test.js +0 -51
- package/dist/esm/utils/sum-usage.test.js +0 -164
- package/dist/esm/utils/usage-rows.test.js +0 -189
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/license.txt +0 -46
- package/sandbox/tsconfig.json +0 -8
- package/src/channel/ai-activity-bus.ts +0 -209
- package/src/channel/ai-activity-channel.ts +0 -146
- package/src/chat-driver-node.ts +0 -159
- package/src/components/activity-halo/activity-halo.ts +0 -106
- package/src/components/agent-picker/agent-picker.constants.ts +0 -2
- package/src/components/agent-picker/agent-picker.styles.ts +0 -24
- package/src/components/agent-picker/agent-picker.template.ts +0 -54
- package/src/components/agent-picker/agent-picker.ts +0 -99
- package/src/components/agent-picker/index.ts +0 -1
- package/src/components/ai-driver/ai-driver.ts +0 -166
- package/src/components/ai-driver/index.ts +0 -1
- package/src/components/chat-bubble/chat-bubble.styles.ts +0 -194
- package/src/components/chat-bubble/chat-bubble.template.ts +0 -67
- package/src/components/chat-bubble/chat-bubble.ts +0 -436
- package/src/components/chat-bubble/index.ts +0 -1
- package/src/components/chat-driver/align-event-globals.ts +0 -23
- package/src/components/chat-driver/chat-driver.compact.test.ts +0 -131
- package/src/components/chat-driver/chat-driver.invocation-scope.test.ts +0 -285
- package/src/components/chat-driver/chat-driver.test.ts +0 -3679
- package/src/components/chat-driver/chat-driver.thinking-policy.test.ts +0 -185
- package/src/components/chat-driver/chat-driver.trace-capture.test.ts +0 -251
- package/src/components/chat-driver/chat-driver.ts +0 -3925
- package/src/components/chat-driver/chat-driver.turn-usage.test.ts +0 -362
- package/src/components/chat-driver/index.ts +0 -1
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.styles.ts +0 -7
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.template.ts +0 -6
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.test.ts +0 -144
- package/src/components/chat-interaction-wrapper/chat-interaction-wrapper.ts +0 -243
- package/src/components/chat-interaction-wrapper/index.ts +0 -3
- package/src/components/chat-markdown/chat-markdown.ts +0 -89
- package/src/components/chat-markdown/index.ts +0 -1
- package/src/components/flowing-waves-indicator.ts +0 -286
- package/src/components/halo-overlay.ts +0 -184
- package/src/components/orchestrating-driver/index.ts +0 -1
- package/src/components/orchestrating-driver/orchestrating-driver.budget.test.ts +0 -438
- package/src/components/orchestrating-driver/orchestrating-driver.cost.test.ts +0 -107
- package/src/components/orchestrating-driver/orchestrating-driver.pin.test.ts +0 -96
- package/src/components/orchestrating-driver/orchestrating-driver.ts +0 -813
- package/src/components/plasma-orb-indicator.ts +0 -281
- package/src/components/popout-manager/index.ts +0 -1
- package/src/components/popout-manager/popout-manager.ts +0 -212
- package/src/components/settings-modal/index.ts +0 -2
- package/src/components/settings-modal/settings-modal.styles.test.ts +0 -94
- package/src/components/settings-modal/settings-modal.styles.ts +0 -853
- package/src/components/settings-modal/settings-modal.template.test.ts +0 -124
- package/src/components/settings-modal/settings-modal.template.ts +0 -943
- package/src/components/waves-indicator.ts +0 -241
- package/src/config/config.ts +0 -608
- package/src/config/define-stateful-agent.test.ts +0 -64
- package/src/config/define-stateful-agent.ts +0 -642
- package/src/config/fallback-agents.ts +0 -29
- package/src/config/index.ts +0 -2
- package/src/config/validate-providers.test.ts +0 -148
- package/src/config/validate-providers.ts +0 -58
- package/src/index.ts +0 -29
- package/src/main/blocked-state.test.ts +0 -1444
- package/src/main/budget-meter.test.ts +0 -477
- package/src/main/cost-session-banking.test.ts +0 -679
- package/src/main/file-attachments.test.ts +0 -215
- package/src/main/index.ts +0 -3
- package/src/main/interaction-cost.test.ts +0 -140
- package/src/main/main.styles.test.ts +0 -216
- package/src/main/main.styles.ts +0 -995
- package/src/main/main.template.ts +0 -1119
- package/src/main/main.ts +0 -5432
- package/src/main/main.types.ts +0 -225
- package/src/main/persistence-broken-sources.test.ts +0 -219
- package/src/main/popout-interaction-gate.test.ts +0 -87
- package/src/provider/ai-provider-switcher.ts +0 -40
- package/src/provider/assistant-app-settings.ts +0 -84
- package/src/state/ai-assistant-slice.test.ts +0 -336
- package/src/state/ai-assistant-slice.ts +0 -604
- package/src/state/debug-event-log.test.ts +0 -154
- package/src/state/debug-event-log.ts +0 -358
- package/src/state/driver-registry.test.ts +0 -70
- package/src/state/driver-registry.ts +0 -79
- package/src/state/interaction-context.test.ts +0 -90
- package/src/state/interaction-context.ts +0 -101
- package/src/state/persistence/build-timeline-entries.ts +0 -66
- package/src/state/persistence/diagnostics-cursors.test.ts +0 -53
- package/src/state/persistence/diagnostics-cursors.ts +0 -80
- package/src/state/persistence/diagnostics.test.ts +0 -346
- package/src/state/persistence/diagnostics.ts +0 -220
- package/src/state/persistence/index.ts +0 -10
- package/src/state/persistence/persister-registry.ts +0 -55
- package/src/state/persistence/session-persistence-provider.test.ts +0 -136
- package/src/state/persistence/session-persistence-provider.ts +0 -226
- package/src/state/persistence/session-persistence.integration.test.ts +0 -141
- package/src/state/persistence/session-persister.test.ts +0 -787
- package/src/state/persistence/session-persister.ts +0 -917
- package/src/state/persistence/session-snapshot.test.ts +0 -352
- package/src/state/persistence/session-snapshot.ts +0 -344
- package/src/state/persistence/stateful-restore.e2e.test.ts +0 -229
- package/src/state/session-store.ts +0 -56
- package/src/styles/ai-colours.ts +0 -30
- package/src/styles/menu.ts +0 -187
- package/src/styles/settings-section.ts +0 -40
- package/src/suggestions/chat-suggestions.ts +0 -186
- package/src/tags/index.ts +0 -1
- package/src/types/ai-chat-widget.ts +0 -67
- package/src/types/interaction-context.ts +0 -82
- package/src/utils/animated-panel-toggle.ts +0 -62
- package/src/utils/animation-exclusivity.test.ts +0 -72
- package/src/utils/animation-exclusivity.ts +0 -40
- package/src/utils/banked-usage-baselines.ts +0 -90
- package/src/utils/collect-session-models.test.ts +0 -82
- package/src/utils/collect-session-models.ts +0 -65
- package/src/utils/condense-history.test.ts +0 -746
- package/src/utils/condense-history.ts +0 -448
- package/src/utils/cost-session-history.test.ts +0 -226
- package/src/utils/cost-session-history.ts +0 -218
- package/src/utils/derive-cost-session-title.test.ts +0 -63
- package/src/utils/derive-cost-session-title.ts +0 -76
- package/src/utils/flatten-sub-agent-messages.test.ts +0 -163
- package/src/utils/flatten-sub-agent-messages.ts +0 -88
- package/src/utils/format-usd.ts +0 -26
- package/src/utils/history-transform.test.ts +0 -228
- package/src/utils/history-transform.ts +0 -163
- package/src/utils/index.ts +0 -3
- package/src/utils/logger.ts +0 -3
- package/src/utils/message-partition.test.ts +0 -129
- package/src/utils/message-partition.ts +0 -115
- package/src/utils/resolve-cost-history-config.test.ts +0 -38
- package/src/utils/resolve-cost-history-config.ts +0 -38
- package/src/utils/resolve-preference-baseline.test.ts +0 -84
- package/src/utils/resolve-preference-baseline.ts +0 -80
- package/src/utils/strip-agent-handlers.test.ts +0 -99
- package/src/utils/strip-agent-handlers.ts +0 -53
- package/src/utils/sum-costs.test.ts +0 -136
- package/src/utils/sum-costs.ts +0 -18
- package/src/utils/sum-tokens.test.ts +0 -93
- package/src/utils/sum-tokens.ts +0 -68
- package/src/utils/sum-usage.test.ts +0 -191
- package/src/utils/sum-usage.ts +0 -208
- package/src/utils/tool-fold.ts +0 -181
- package/src/utils/usage-rows.test.ts +0 -237
- package/src/utils/usage-rows.ts +0 -187
- package/src/utils/with-timeout.ts +0 -24
- package/tsconfig.json +0 -9
|
@@ -1,787 +0,0 @@
|
|
|
1
|
-
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
|
-
import { assert, createLogicSuite } from '@genesislcap/foundation-testing';
|
|
3
|
-
import { clearMetaEventRegistry, getMetaEvents } from '../debug-event-log';
|
|
4
|
-
import type { DiagnosticEntry } from './diagnostics';
|
|
5
|
-
import { clearDiagnosticsCursorRegistry } from './diagnostics-cursors';
|
|
6
|
-
import { clearPersisterRegistry } from './persister-registry';
|
|
7
|
-
import type { SessionPreferences } from './session-persistence-provider';
|
|
8
|
-
import { SessionPersister, type SessionPersisterDeps } from './session-persister';
|
|
9
|
-
import { createPersistedSession, type PersistedSession } from './session-snapshot';
|
|
10
|
-
|
|
11
|
-
// GENC-1351 §5.10: the shared per-session persister owns restore-core save/restore/
|
|
12
|
-
// freeze. Unit-tested in isolation with fake driver/store/provider — the win the
|
|
13
|
-
// extraction buys (this logic was previously only exercised by manual testing).
|
|
14
|
-
|
|
15
|
-
const HOST = window.location.host;
|
|
16
|
-
const KEY = 'session-1';
|
|
17
|
-
const msg = (o: Partial<ChatMessage>): ChatMessage =>
|
|
18
|
-
({ role: 'user', content: '', ...o }) as ChatMessage;
|
|
19
|
-
const tick = (): Promise<void> =>
|
|
20
|
-
new Promise((r) => {
|
|
21
|
-
setTimeout(r, 0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
interface Fakes {
|
|
25
|
-
saved: PersistedSession[];
|
|
26
|
-
cleared: number;
|
|
27
|
-
loadResult: PersistedSession | null;
|
|
28
|
-
loadedHistory: ChatMessage[][];
|
|
29
|
-
primed: unknown[];
|
|
30
|
-
loadSessionCalls: unknown[];
|
|
31
|
-
inputValues: string[];
|
|
32
|
-
history: ChatMessage[];
|
|
33
|
-
activeAgent: Record<string, unknown> | undefined;
|
|
34
|
-
enabled: boolean;
|
|
35
|
-
restoring: boolean;
|
|
36
|
-
/** Diagnostic entries the element would build (Phase 3). */
|
|
37
|
-
diagnosticEntries: DiagnosticEntry[];
|
|
38
|
-
/** Batches passed to provider.appendDiagnostics. */
|
|
39
|
-
appended: DiagnosticEntry[][];
|
|
40
|
-
/** Blobs passed to provider.savePreferences. */
|
|
41
|
-
savedPrefs: SessionPreferences[];
|
|
42
|
-
/** What provider.loadPreferences returns. */
|
|
43
|
-
loadPrefsResult: SessionPreferences | undefined;
|
|
44
|
-
/** Set false to omit `appendDiagnostics`/`savePreferences` from the provider. */
|
|
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;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function makePersister(over: Partial<Fakes> = {}): { p: SessionPersister; f: Fakes } {
|
|
55
|
-
const f: Fakes = {
|
|
56
|
-
saved: [],
|
|
57
|
-
cleared: 0,
|
|
58
|
-
loadResult: null,
|
|
59
|
-
loadedHistory: [],
|
|
60
|
-
primed: [],
|
|
61
|
-
loadSessionCalls: [],
|
|
62
|
-
inputValues: [],
|
|
63
|
-
history: [],
|
|
64
|
-
activeAgent: undefined,
|
|
65
|
-
enabled: true,
|
|
66
|
-
restoring: false,
|
|
67
|
-
diagnosticEntries: [],
|
|
68
|
-
appended: [],
|
|
69
|
-
savedPrefs: [],
|
|
70
|
-
loadPrefsResult: undefined,
|
|
71
|
-
diagnosticsCapable: true,
|
|
72
|
-
saveError: undefined,
|
|
73
|
-
brokenReports: [],
|
|
74
|
-
recovered: 0,
|
|
75
|
-
...over,
|
|
76
|
-
};
|
|
77
|
-
const provider = {
|
|
78
|
-
saveDebounceMs: 0,
|
|
79
|
-
diagnosticsDebounceMs: 0,
|
|
80
|
-
save: async (_k: string, snap: PersistedSession) => {
|
|
81
|
-
if (f.saveError) throw f.saveError;
|
|
82
|
-
f.saved.push(snap);
|
|
83
|
-
},
|
|
84
|
-
load: async () => f.loadResult,
|
|
85
|
-
clear: async () => {
|
|
86
|
-
f.cleared += 1;
|
|
87
|
-
},
|
|
88
|
-
loadPreferences: async () => f.loadPrefsResult,
|
|
89
|
-
...(f.diagnosticsCapable
|
|
90
|
-
? {
|
|
91
|
-
appendDiagnostics: async (_k: string, entries: DiagnosticEntry[]) => {
|
|
92
|
-
f.appended.push(entries);
|
|
93
|
-
},
|
|
94
|
-
savePreferences: async (_k: string, prefs: SessionPreferences) => {
|
|
95
|
-
f.savedPrefs.push(prefs);
|
|
96
|
-
},
|
|
97
|
-
}
|
|
98
|
-
: {}),
|
|
99
|
-
};
|
|
100
|
-
const driver = {
|
|
101
|
-
getRawHistory: () => f.history,
|
|
102
|
-
getActiveAgent: () => f.activeAgent,
|
|
103
|
-
loadHistory: (h: ChatMessage[]) => f.loadedHistory.push(h),
|
|
104
|
-
primeRestoredAgentState: (s: unknown) => f.primed.push(s),
|
|
105
|
-
setPinnedAgent: () => {},
|
|
106
|
-
};
|
|
107
|
-
const store = {
|
|
108
|
-
store: {
|
|
109
|
-
aiAssistant: {
|
|
110
|
-
messages: f.history,
|
|
111
|
-
flowOwnerAgentName: null,
|
|
112
|
-
flowActivationPrompt: null,
|
|
113
|
-
activeAgent: undefined,
|
|
114
|
-
pinnedAgentName: null,
|
|
115
|
-
// Inline rather than via `emptyUsage()` to keep this mock self-contained. The
|
|
116
|
-
// shape matters: the persister reads `sessionUsage.costUsd`, so a mock missing
|
|
117
|
-
// the object crashes the autosave timer rather than failing an assertion.
|
|
118
|
-
sessionUsage: {
|
|
119
|
-
costUsd: 0,
|
|
120
|
-
uncachedInputTokens: 0,
|
|
121
|
-
cacheReadTokens: 0,
|
|
122
|
-
cacheWriteTokens: 0,
|
|
123
|
-
outputTokens: 0,
|
|
124
|
-
},
|
|
125
|
-
get restoring() {
|
|
126
|
-
return f.restoring;
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
actions: {
|
|
131
|
-
aiAssistant: {
|
|
132
|
-
loadSession: (p: unknown) => f.loadSessionCalls.push(p),
|
|
133
|
-
setInputValue: (v: string) => f.inputValues.push(v),
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
};
|
|
137
|
-
const deps: SessionPersisterDeps = {
|
|
138
|
-
stateKey: KEY,
|
|
139
|
-
getPersistence: () => ({ enabled: f.enabled, provider: provider as never }),
|
|
140
|
-
getAgentNames: () => [],
|
|
141
|
-
getDriver: () => driver as never,
|
|
142
|
-
getStore: () => store as never,
|
|
143
|
-
getDiagnosticEntries: () => f.diagnosticEntries,
|
|
144
|
-
onPersistenceBroken: (d) => f.brokenReports.push(d),
|
|
145
|
-
onPersistenceRecovered: () => {
|
|
146
|
-
f.recovered += 1;
|
|
147
|
-
},
|
|
148
|
-
};
|
|
149
|
-
return { p: new SessionPersister(deps), f };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
const Suite = createLogicSuite('SessionPersister');
|
|
153
|
-
|
|
154
|
-
Suite.before.each(() => {
|
|
155
|
-
clearPersisterRegistry();
|
|
156
|
-
clearDiagnosticsCursorRegistry();
|
|
157
|
-
clearMetaEventRegistry();
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
Suite('restore() hydrates the store + driver and appends a "Restored" divider', async () => {
|
|
161
|
-
const snapshot = createPersistedSession({
|
|
162
|
-
sessionKey: KEY,
|
|
163
|
-
host: HOST,
|
|
164
|
-
messages: [msg({ role: 'user', content: 'hi' }), msg({ role: 'assistant', content: 'hello' })],
|
|
165
|
-
pinnedAgentName: null,
|
|
166
|
-
flowOwnerAgentName: null,
|
|
167
|
-
sessionCostUsd: 0,
|
|
168
|
-
});
|
|
169
|
-
const { p, f } = makePersister({ loadResult: snapshot });
|
|
170
|
-
await p.restore();
|
|
171
|
-
assert.is(f.loadSessionCalls.length, 1, 'store hydrated once');
|
|
172
|
-
assert.is(f.loadedHistory.length, 1, 'driver history loaded once');
|
|
173
|
-
const loaded = f.loadedHistory[0];
|
|
174
|
-
assert.is(loaded.length, 3, 'two restored messages + a trailer');
|
|
175
|
-
assert.is(loaded[2].role, 'system-event');
|
|
176
|
-
assert.is(f.inputValues.length, 0, 'no composer prefill for a normal restore');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
Suite(
|
|
180
|
-
'restore() of an interrupted snapshot re-appends the triggering message + notice + pre-fills',
|
|
181
|
-
async () => {
|
|
182
|
-
const snapshot: PersistedSession = {
|
|
183
|
-
...createPersistedSession({
|
|
184
|
-
sessionKey: KEY,
|
|
185
|
-
host: HOST,
|
|
186
|
-
// The frozen snapshot is the prior *resumable* conversation — it does NOT
|
|
187
|
-
// contain the interrupted flow's triggering message; that is re-appended from
|
|
188
|
-
// the `interrupted.prompt` so the exchange reads as a conversation.
|
|
189
|
-
messages: [
|
|
190
|
-
msg({ role: 'user', content: 'hi' }),
|
|
191
|
-
msg({ role: 'assistant', content: 'hello' }),
|
|
192
|
-
],
|
|
193
|
-
pinnedAgentName: null,
|
|
194
|
-
flowOwnerAgentName: null,
|
|
195
|
-
sessionCostUsd: 0,
|
|
196
|
-
}),
|
|
197
|
-
interrupted: { message: 'I did not finish — send it again', prompt: 'do the thing' },
|
|
198
|
-
};
|
|
199
|
-
const { p, f } = makePersister({ loadResult: snapshot });
|
|
200
|
-
await p.restore();
|
|
201
|
-
const loaded = f.loadedHistory[0];
|
|
202
|
-
// …prior conversation, user('do the thing'), assistant(notice)
|
|
203
|
-
assert.is(
|
|
204
|
-
loaded[loaded.length - 2].role,
|
|
205
|
-
'user',
|
|
206
|
-
'triggering message re-appended before the notice',
|
|
207
|
-
);
|
|
208
|
-
assert.is(loaded[loaded.length - 2].content, 'do the thing');
|
|
209
|
-
assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
|
|
210
|
-
assert.is(loaded[loaded.length - 1].content, 'I did not finish — send it again');
|
|
211
|
-
assert.equal(f.inputValues, ['do the thing'], 'composer pre-filled with the flow prompt');
|
|
212
|
-
},
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
Suite(
|
|
216
|
-
'restore() of an interrupted snapshot without a captured prompt appends only the notice',
|
|
217
|
-
async () => {
|
|
218
|
-
const snapshot: PersistedSession = {
|
|
219
|
-
...createPersistedSession({
|
|
220
|
-
sessionKey: KEY,
|
|
221
|
-
host: HOST,
|
|
222
|
-
messages: [
|
|
223
|
-
msg({ role: 'user', content: 'hi' }),
|
|
224
|
-
msg({ role: 'assistant', content: 'hello' }),
|
|
225
|
-
],
|
|
226
|
-
pinnedAgentName: null,
|
|
227
|
-
flowOwnerAgentName: null,
|
|
228
|
-
sessionCostUsd: 0,
|
|
229
|
-
}),
|
|
230
|
-
interrupted: { message: 'I did not finish — send it again' },
|
|
231
|
-
};
|
|
232
|
-
const { p, f } = makePersister({ loadResult: snapshot });
|
|
233
|
-
await p.restore();
|
|
234
|
-
const loaded = f.loadedHistory[0];
|
|
235
|
-
assert.is(loaded.length, 3, 'prior 2 messages + notice only — no synthesized user message');
|
|
236
|
-
assert.is(loaded[loaded.length - 1].role, 'assistant', 'notice is an assistant bubble');
|
|
237
|
-
assert.equal(f.inputValues, [], 'no composer pre-fill without a captured prompt');
|
|
238
|
-
},
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
Suite(
|
|
242
|
-
'restore() of a fresh-freeze interrupted snapshot does NOT duplicate the trigger message',
|
|
243
|
-
async () => {
|
|
244
|
-
// The fresh-freeze fallback (no prior resumable snapshot) keeps the transcript
|
|
245
|
-
// THROUGH the last user message (`keepThroughLastUserMessage`), so the triggering
|
|
246
|
-
// prompt is already the final message. restore() must NOT re-append it from
|
|
247
|
-
// `interrupted.prompt` — doing so duplicated the bubble on a fresh session that
|
|
248
|
-
// went non-resumable on its first turn (GENC-1351 §5.9).
|
|
249
|
-
const snapshot: PersistedSession = {
|
|
250
|
-
...createPersistedSession({
|
|
251
|
-
sessionKey: KEY,
|
|
252
|
-
host: HOST,
|
|
253
|
-
messages: [msg({ role: 'user', content: 'cancel trade T123' })],
|
|
254
|
-
pinnedAgentName: null,
|
|
255
|
-
flowOwnerAgentName: null,
|
|
256
|
-
sessionCostUsd: 0,
|
|
257
|
-
}),
|
|
258
|
-
interrupted: { message: 'I did not finish — send it again', prompt: 'cancel trade T123' },
|
|
259
|
-
};
|
|
260
|
-
const { p, f } = makePersister({ loadResult: snapshot });
|
|
261
|
-
await p.restore();
|
|
262
|
-
const loaded = f.loadedHistory[0];
|
|
263
|
-
assert.is(loaded.filter((m) => m.role === 'user').length, 1, 'trigger appears once');
|
|
264
|
-
assert.is(loaded.length, 2, 'trigger + notice only — no duplicate user bubble');
|
|
265
|
-
assert.is(loaded[0].content, 'cancel trade T123');
|
|
266
|
-
assert.is(loaded[1].role, 'assistant', 'notice is an assistant bubble');
|
|
267
|
-
assert.equal(f.inputValues, ['cancel trade T123'], 'composer still pre-filled with the prompt');
|
|
268
|
-
},
|
|
269
|
-
);
|
|
270
|
-
|
|
271
|
-
Suite('restore() with nothing stored is a silent no-op', async () => {
|
|
272
|
-
const { p, f } = makePersister({ loadResult: null });
|
|
273
|
-
await p.restore();
|
|
274
|
-
assert.is(f.loadSessionCalls.length, 0);
|
|
275
|
-
assert.is(f.loadedHistory.length, 0);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
Suite('scheduleSave() persists the current transcript (single debounced save)', async () => {
|
|
279
|
-
const history = [msg({ role: 'user', content: 'save me' })];
|
|
280
|
-
const { p, f } = makePersister({ history });
|
|
281
|
-
p.scheduleSave();
|
|
282
|
-
p.scheduleSave(); // second view — must coalesce into ONE save
|
|
283
|
-
await tick();
|
|
284
|
-
await tick();
|
|
285
|
-
assert.is(f.saved.length, 1, 'coalesced to a single save');
|
|
286
|
-
assert.is(f.saved[0].messages.length, 1);
|
|
287
|
-
assert.is(f.saved[0].messages[0].content, 'save me');
|
|
288
|
-
assert.is(f.saved[0].interrupted, undefined, 'a normal save carries no interrupted marker');
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
Suite('scheduleSave() is a no-op when persistence is disabled', async () => {
|
|
292
|
-
const { p, f } = makePersister({ enabled: false, history: [msg({ content: 'x' })] });
|
|
293
|
-
p.scheduleSave();
|
|
294
|
-
await tick();
|
|
295
|
-
await tick();
|
|
296
|
-
assert.is(f.saved.length, 0);
|
|
297
|
-
});
|
|
298
|
-
|
|
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 });
|
|
384
|
-
p.scheduleSave();
|
|
385
|
-
await tick();
|
|
386
|
-
await tick();
|
|
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');
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
Suite('clearRestoreCore() drops the snapshot', async () => {
|
|
406
|
-
const { p, f } = makePersister();
|
|
407
|
-
await p.clearRestoreCore();
|
|
408
|
-
assert.is(f.cleared, 1);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
// ── Phase 2: preferences ────────────────────────────────────────────────────
|
|
412
|
-
|
|
413
|
-
Suite(
|
|
414
|
-
'loadPreferences() populates storedPreferences from the provider blob (null when none)',
|
|
415
|
-
async () => {
|
|
416
|
-
const prefs: SessionPreferences = { enabled: true, showToolCalls: true };
|
|
417
|
-
const { p } = makePersister({ loadPrefsResult: prefs });
|
|
418
|
-
await p.loadPreferences();
|
|
419
|
-
assert.equal(p.storedPreferences, prefs);
|
|
420
|
-
|
|
421
|
-
const { p: p2 } = makePersister(); // provider present, nothing stored
|
|
422
|
-
await p2.loadPreferences();
|
|
423
|
-
assert.is(p2.storedPreferences, null);
|
|
424
|
-
},
|
|
425
|
-
);
|
|
426
|
-
|
|
427
|
-
Suite(
|
|
428
|
-
'savePreferences() persists, records the (shared) user change, and refreshes storedPreferences',
|
|
429
|
-
() => {
|
|
430
|
-
const blob: SessionPreferences = { enabled: false, showNarration: true };
|
|
431
|
-
const { p, f } = makePersister();
|
|
432
|
-
assert.is(p.preferencesUserChanged, false);
|
|
433
|
-
p.savePreferences(blob);
|
|
434
|
-
assert.equal(f.savedPrefs, [blob]);
|
|
435
|
-
assert.is(p.preferencesUserChanged, true, 'the shared latch is set so the reconciler freezes');
|
|
436
|
-
assert.equal(p.storedPreferences, blob, 'stored blob kept fresh for a later re-reconcile');
|
|
437
|
-
|
|
438
|
-
// No savePreferences on the provider → no write, but the user change is still
|
|
439
|
-
// recorded (freezing the baseline regardless of a persistence-capable backend).
|
|
440
|
-
const { p: p2, f: f2 } = makePersister({ diagnosticsCapable: false });
|
|
441
|
-
p2.savePreferences(blob);
|
|
442
|
-
assert.is(f2.savedPrefs.length, 0);
|
|
443
|
-
assert.is(p2.preferencesUserChanged, true);
|
|
444
|
-
},
|
|
445
|
-
);
|
|
446
|
-
|
|
447
|
-
// ── Phase 3: diagnostics-append ───────────────────────────────────────────────
|
|
448
|
-
|
|
449
|
-
const evt = (index: number): DiagnosticEntry => ({
|
|
450
|
-
kind: 'event',
|
|
451
|
-
type: 'turn.start',
|
|
452
|
-
index,
|
|
453
|
-
timestamp: `2026-01-01T00:00:0${index}.000Z`,
|
|
454
|
-
});
|
|
455
|
-
|
|
456
|
-
const metaSnap = (dedupSignature: string, meta: unknown): DiagnosticEntry => ({
|
|
457
|
-
kind: 'meta-snapshot',
|
|
458
|
-
timestamp: '2026-01-01T00:00:00.000Z',
|
|
459
|
-
meta,
|
|
460
|
-
dedupSignature,
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
// A diagnostic MESSAGE entry as the reasoning/narration/answer split (GENC-1411) produces them —
|
|
464
|
-
// several messages from ONE response, sharing timestamp + role, differing by `category` / content.
|
|
465
|
-
const diagMsg = (over: Partial<DiagnosticEntry> = {}): DiagnosticEntry => ({
|
|
466
|
-
kind: 'message',
|
|
467
|
-
role: 'assistant',
|
|
468
|
-
timestamp: '2026-01-01T00:00:05.000Z',
|
|
469
|
-
content: '',
|
|
470
|
-
...over,
|
|
471
|
-
});
|
|
472
|
-
|
|
473
|
-
Suite('scheduleDiagnostics() appends the collected delta once per window', async () => {
|
|
474
|
-
const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
|
|
475
|
-
p.scheduleDiagnostics();
|
|
476
|
-
p.scheduleDiagnostics(); // second view → coalesced
|
|
477
|
-
await tick();
|
|
478
|
-
await tick();
|
|
479
|
-
assert.is(f.appended.length, 1, 'one append despite two schedules');
|
|
480
|
-
assert.is(f.appended[0].length, 2);
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
Suite('scheduleDiagnostics() holds off while the session is restoring', async () => {
|
|
484
|
-
const { p, f } = makePersister({ diagnosticEntries: [evt(0)], restoring: true });
|
|
485
|
-
p.scheduleDiagnostics();
|
|
486
|
-
await tick();
|
|
487
|
-
await tick();
|
|
488
|
-
assert.is(f.appended.length, 0, 'no capture mid-restore (watermark not yet set)');
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
Suite('flushDiagnostics() appends immediately and de-dups via the shared cursors', async () => {
|
|
492
|
-
const { p, f } = makePersister({ diagnosticEntries: [evt(0), evt(1)] });
|
|
493
|
-
await p.flushDiagnostics();
|
|
494
|
-
assert.is(f.appended.length, 1, 'first flush appends the two events');
|
|
495
|
-
assert.is(f.appended[0].length, 2);
|
|
496
|
-
// Nothing new since — the event cursor has advanced, so the delta is empty.
|
|
497
|
-
await p.flushDiagnostics();
|
|
498
|
-
assert.is(f.appended.length, 1, 'no re-append of already-emitted events');
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
Suite('flushDiagnostics() is a no-op without a diagnostics-capable provider', async () => {
|
|
502
|
-
const { p, f } = makePersister({ diagnosticsCapable: false, diagnosticEntries: [evt(0)] });
|
|
503
|
-
await p.flushDiagnostics();
|
|
504
|
-
assert.is(f.appended.length, 0);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
Suite(
|
|
508
|
-
'flushDiagnostics() dedups meta-snapshots by dedupSignature, not the volatile meta',
|
|
509
|
-
async () => {
|
|
510
|
-
// Regression for #12: the guard compared JSON.stringify(entry.meta), which embeds a
|
|
511
|
-
// fresh timestamp + per-turn context, so it never matched → the bulky block was
|
|
512
|
-
// re-appended on every flush. It must dedup on the stable config signature instead.
|
|
513
|
-
const { p, f } = makePersister({
|
|
514
|
-
diagnosticEntries: [metaSnap('cfg-1', { timestamp: 't1', context: { tokens: 1 } })],
|
|
515
|
-
});
|
|
516
|
-
await p.flushDiagnostics();
|
|
517
|
-
assert.is(f.appended.length, 1, 'first meta-snapshot appended');
|
|
518
|
-
|
|
519
|
-
// Same config signature, but the volatile meta changed (new timestamp + tokens) → skip.
|
|
520
|
-
f.diagnosticEntries = [metaSnap('cfg-1', { timestamp: 't2', context: { tokens: 999 } })];
|
|
521
|
-
await p.flushDiagnostics();
|
|
522
|
-
assert.is(f.appended.length, 1, 'unchanged config → no re-append despite volatile churn');
|
|
523
|
-
|
|
524
|
-
// Config signature changed → append again.
|
|
525
|
-
f.diagnosticEntries = [metaSnap('cfg-2', { timestamp: 't3', context: { tokens: 999 } })];
|
|
526
|
-
await p.flushDiagnostics();
|
|
527
|
-
assert.is(f.appended.length, 2, 'changed config → re-appended');
|
|
528
|
-
},
|
|
529
|
-
);
|
|
530
|
-
|
|
531
|
-
Suite(
|
|
532
|
-
'flushDiagnostics() keeps co-timestamped split messages (reasoning + answer), not deduped as one',
|
|
533
|
-
async () => {
|
|
534
|
-
// GENC-1461: the reasoning/narration/answer split emits several messages from ONE response, so
|
|
535
|
-
// they share timestamp + role + subAgentOf. The forward-capture keyed only on those, so it
|
|
536
|
-
// emitted the reasoning message and dropped the answer as a "duplicate" — a server generation's
|
|
537
|
-
// answer message vanished from the persisted download while its reasoning survived. Both must be
|
|
538
|
-
// captured.
|
|
539
|
-
const reasoning = diagMsg({ category: 'reasoning', content: 'thinking…' });
|
|
540
|
-
const answer = diagMsg({ content: 'the generated code' }); // no category = the answer/tool-call slot
|
|
541
|
-
const { p, f } = makePersister({ diagnosticEntries: [reasoning, answer] });
|
|
542
|
-
await p.flushDiagnostics();
|
|
543
|
-
assert.is(f.appended.length, 1, 'one flush');
|
|
544
|
-
assert.is(
|
|
545
|
-
f.appended[0].length,
|
|
546
|
-
2,
|
|
547
|
-
'both the reasoning and the answer message are captured, not collapsed to one',
|
|
548
|
-
);
|
|
549
|
-
},
|
|
550
|
-
);
|
|
551
|
-
|
|
552
|
-
Suite('flushDiagnostics() re-emits a turn once its per-call usage lands', async () => {
|
|
553
|
-
// A turn entry is created BEFORE its model call and priced when the response returns,
|
|
554
|
-
// so a flush landing in between persists it unpriced. Emitting it exactly once more —
|
|
555
|
-
// when `usage` is there — is what keeps per-call cost in a stitched lifetime log;
|
|
556
|
-
// `assembleDebugLog` collapses the pair back onto the priced copy.
|
|
557
|
-
const inFlight: DiagnosticEntry = {
|
|
558
|
-
kind: 'turn',
|
|
559
|
-
turnIndex: '0',
|
|
560
|
-
timestamp: '2026-01-01T00:00:01.000Z',
|
|
561
|
-
toolNames: [],
|
|
562
|
-
};
|
|
563
|
-
const { p, f } = makePersister({ diagnosticEntries: [inFlight] });
|
|
564
|
-
await p.flushDiagnostics();
|
|
565
|
-
assert.is(f.appended.length, 1, 'the unpriced turn is captured');
|
|
566
|
-
assert.is(f.appended[0][0].usage, undefined);
|
|
567
|
-
|
|
568
|
-
const priced: DiagnosticEntry = {
|
|
569
|
-
...inFlight,
|
|
570
|
-
usage: {
|
|
571
|
-
costUsd: 0.25,
|
|
572
|
-
uncachedInputTokens: 100,
|
|
573
|
-
cacheReadTokens: 900,
|
|
574
|
-
cacheWriteTokens: 0,
|
|
575
|
-
outputTokens: 40,
|
|
576
|
-
},
|
|
577
|
-
};
|
|
578
|
-
f.diagnosticEntries = [priced];
|
|
579
|
-
await p.flushDiagnostics();
|
|
580
|
-
assert.is(f.appended.length, 2, 'the priced copy is appended');
|
|
581
|
-
assert.equal(f.appended[1][0].usage, priced.usage);
|
|
582
|
-
|
|
583
|
-
// ...and only once. A throttled flush runs on every transcript change, so a turn that
|
|
584
|
-
// re-emitted per flush would multiply the biggest entries in the stream.
|
|
585
|
-
await p.flushDiagnostics();
|
|
586
|
-
assert.is(f.appended.length, 2, 'no further re-append of the priced turn');
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
Suite(
|
|
590
|
-
'flushDiagnostics() still dedups a genuine re-append of the same message across flushes',
|
|
591
|
-
async () => {
|
|
592
|
-
// The dedup must still stop the SAME message being re-appended on every throttled flush.
|
|
593
|
-
const m = diagMsg({ category: 'reasoning', content: 'thinking…' });
|
|
594
|
-
const { p, f } = makePersister({ diagnosticEntries: [m] });
|
|
595
|
-
await p.flushDiagnostics();
|
|
596
|
-
assert.is(f.appended.length, 1, 'first flush appends the message');
|
|
597
|
-
await p.flushDiagnostics();
|
|
598
|
-
assert.is(f.appended.length, 1, 'unchanged message → no re-append');
|
|
599
|
-
},
|
|
600
|
-
);
|
|
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
|
-
|
|
787
|
-
Suite.run();
|