@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,917 +0,0 @@
|
|
|
1
|
-
import type { ChatMessage } from '@genesislcap/foundation-ai';
|
|
2
|
-
import type { AiDriver } from '../../components/ai-driver/ai-driver';
|
|
3
|
-
import { OrchestratingDriver } from '../../components/orchestrating-driver/orchestrating-driver';
|
|
4
|
-
import { flattenSubAgentMessages } from '../../utils/flatten-sub-agent-messages';
|
|
5
|
-
import { logger } from '../../utils/logger';
|
|
6
|
-
import {
|
|
7
|
-
isSessionNoticeMarker,
|
|
8
|
-
SESSION_RESTORE_FAILED_LABEL,
|
|
9
|
-
SESSION_RESTORED_LABEL,
|
|
10
|
-
} from '../../utils/message-partition';
|
|
11
|
-
import { withTimeout } from '../../utils/with-timeout';
|
|
12
|
-
import { recordMetaEvent, type MetaEventType } from '../debug-event-log';
|
|
13
|
-
import type { SessionStoreReturn } from '../session-store';
|
|
14
|
-
import { diagnosticsMessageKey, type DiagnosticEntry } from './diagnostics';
|
|
15
|
-
import { getDiagnosticsCursors } from './diagnostics-cursors';
|
|
16
|
-
import type { SessionPersistenceConfig, SessionPreferences } from './session-persistence-provider';
|
|
17
|
-
import {
|
|
18
|
-
createPersistedSession,
|
|
19
|
-
isRestorableSession,
|
|
20
|
-
keepThroughLastUserMessage,
|
|
21
|
-
type PersistedSession,
|
|
22
|
-
reconcileAgentSnapshots,
|
|
23
|
-
resolveNonResumableNotice,
|
|
24
|
-
toLoadSessionPayload,
|
|
25
|
-
trimToRestorableBoundary,
|
|
26
|
-
unknownAgentSnapshots,
|
|
27
|
-
} from './session-snapshot';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Backstop for a slow/hung persistence provider on startup restore (GENC-1351
|
|
31
|
-
* §6). Deliberately long: silently losing a restorable session is a far worse
|
|
32
|
-
* outcome than a long "Restoring…" wait, so we give a slow backend plenty of room
|
|
33
|
-
* and only give up (→ fresh session + a "couldn't restore" notice) when a load is
|
|
34
|
-
* genuinely stuck. A healthy server responds well under this. Bounds both the
|
|
35
|
-
* session `load` and the preferences load.
|
|
36
|
-
*/
|
|
37
|
-
const RESTORE_TIMEOUT_MS = 30_000;
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Default throttle between diagnostics appends (GENC-1351 §5.8) when the provider
|
|
41
|
-
* doesn't specify `diagnosticsDebounceMs`. Coarser than the restore-core save — the
|
|
42
|
-
* debug log is heavy and doesn't need per-change freshness.
|
|
43
|
-
*/
|
|
44
|
-
const DEFAULT_DIAGNOSTICS_DEBOUNCE_MS = 4_000;
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Fallback notice shown on return when an agent declared its state non-resumable
|
|
48
|
-
* (GENC-1351 §5.9) and set no `notResumableMessage`. Paired with the composer
|
|
49
|
-
* pre-filled with the flow-activating prompt, so "send it again" is one keystroke.
|
|
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
|
-
|
|
59
|
-
const DEFAULT_NOT_RESUMABLE_MESSAGE =
|
|
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";
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Dependencies the persister reads through — all getters so it always sees current
|
|
64
|
-
* state and never holds a stale reference (the driver can be rebuilt on
|
|
65
|
-
* agents-change, and the creating view can disconnect). Injected rather than read
|
|
66
|
-
* from the module registries directly so the class is unit-testable with fakes.
|
|
67
|
-
*
|
|
68
|
-
* @internal
|
|
69
|
-
*/
|
|
70
|
-
export interface SessionPersisterDeps {
|
|
71
|
-
/** Session identity — stable for the persister's lifetime. */
|
|
72
|
-
stateKey: string;
|
|
73
|
-
/** Current host persistence config (`enabled` + `provider`). Read fresh each op. */
|
|
74
|
-
getPersistence: () => SessionPersistenceConfig;
|
|
75
|
-
/** Names of the currently-configured agents (for the restore orphan check). */
|
|
76
|
-
getAgentNames: () => string[];
|
|
77
|
-
/** The session's shared driver, or `undefined` before it exists. */
|
|
78
|
-
getDriver: () => AiDriver | undefined;
|
|
79
|
-
/** The session's shared store, or `undefined` before it exists. */
|
|
80
|
-
getStore: () => SessionStoreReturn | undefined;
|
|
81
|
-
/**
|
|
82
|
-
* Build the current diagnostic entries (message/turn/event/meta-snapshot). Stays
|
|
83
|
-
* element-owned — it reads the host's agents, active-agent debug snapshot, meta
|
|
84
|
-
* events, etc. — and is diffed against the shared cursors here (GENC-1351 §5.10).
|
|
85
|
-
*/
|
|
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;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Owns **restore-core** persistence for one session (GENC-1351 §5.10) — the
|
|
107
|
-
* save/restore/freeze of the {@link PersistedSession} snapshot plus the
|
|
108
|
-
* carry-forward cache. Created once per session via `persister-registry.ts` and
|
|
109
|
-
* shared by every element view, so there is a single save timer and a single
|
|
110
|
-
* cache: the per-view double-write races (and the corruption they caused under a
|
|
111
|
-
* non-atomic backend) are gone by construction.
|
|
112
|
-
*
|
|
113
|
-
* Owns all provider I/O for the session: the restore-core snapshot (save/restore/
|
|
114
|
-
* freeze), the user-preferences blob (load/save — the element keeps only the
|
|
115
|
-
* `chatConfig`-defaults reconciler), and the diagnostics-append scheduling (one
|
|
116
|
-
* timer per session; entry-building stays element-owned via `getDiagnosticEntries`).
|
|
117
|
-
*
|
|
118
|
-
* @internal
|
|
119
|
-
*/
|
|
120
|
-
export class SessionPersister {
|
|
121
|
-
private _saveTimer?: ReturnType<typeof setTimeout>;
|
|
122
|
-
private _lastSaveAt = 0;
|
|
123
|
-
private _diagnosticsTimer?: ReturnType<typeof setTimeout>;
|
|
124
|
-
private _lastDiagnosticsAt = 0;
|
|
125
|
-
/**
|
|
126
|
-
* The flow-owner's last-known-good serialized image, carried forward when a
|
|
127
|
-
* post-restore autosave fires before the agent re-activates (its live capture is
|
|
128
|
-
* empty). Seeded on restore. See {@link reconcileAgentSnapshots}.
|
|
129
|
-
*/
|
|
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 = '';
|
|
155
|
-
/**
|
|
156
|
-
* The loaded preferences blob (GENC-1351 §5.10 Phase 2), owned here so it's
|
|
157
|
-
* **shared per session** — the element's `applyPreferenceBaseline` reconciler reads
|
|
158
|
-
* it via {@link storedPreferences}. Kept fresh on every save, so a later re-reconcile
|
|
159
|
-
* uses the latest choices, not the stale startup blob. `null` = loaded, none stored.
|
|
160
|
-
*/
|
|
161
|
-
private _storedPreferences: SessionPreferences | null = null;
|
|
162
|
-
/**
|
|
163
|
-
* Whether the user has changed any preference this session. **Shared per session**
|
|
164
|
-
* (was per-element, which let the two views disagree and one clobber the other's
|
|
165
|
-
* choice — issue #1). Once set, the reconciler freezes so `chatConfig` defaults
|
|
166
|
-
* can't overwrite a live user choice, regardless of which view toggled it.
|
|
167
|
-
*/
|
|
168
|
-
private _preferencesUserChanged = false;
|
|
169
|
-
|
|
170
|
-
constructor(private deps: SessionPersisterDeps) {}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Re-point the dependency getters at the currently-live element view. The
|
|
174
|
-
* persister outlives any single element (it's cached per session in
|
|
175
|
-
* `persister-registry.ts`), so when a later element mounts on the same session
|
|
176
|
-
* — e.g. an SPA teardown + remount — it MUST rebind, or the persister keeps
|
|
177
|
-
* calling the torn-down creator's `getDiagnosticEntries`/`getPersistence`/
|
|
178
|
-
* `getAgentNames` (which read a detached driver/store → empty), silently halting
|
|
179
|
-
* diagnostics + autosave capture (GENC-1351 §5.10). `stateKey` is unchanged.
|
|
180
|
-
*
|
|
181
|
-
* @internal
|
|
182
|
-
*/
|
|
183
|
-
rebindDeps(deps: SessionPersisterDeps): void {
|
|
184
|
-
this.deps = deps;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** The loaded preferences blob (shared per session); read by the element's reconciler. */
|
|
188
|
-
get storedPreferences(): SessionPreferences | null {
|
|
189
|
-
return this._storedPreferences;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/** True once the user has changed any preference this session (shared per session). */
|
|
193
|
-
get preferencesUserChanged(): boolean {
|
|
194
|
-
return this._preferencesUserChanged;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private get stateKey(): string {
|
|
198
|
-
return this.deps.stateKey;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
private logMeta(type: MetaEventType, detail?: Record<string, unknown>): void {
|
|
202
|
-
recordMetaEvent(this.stateKey, type, detail);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Throttled autosave. Called by every view on each transcript change; the single
|
|
207
|
-
* shared timer coalesces them into at most one save per the provider's
|
|
208
|
-
* `saveDebounceMs`. `saveDebounceMs: 0` (e.g. `sessionStorage`) means save as
|
|
209
|
-
* soon as the current tick settles.
|
|
210
|
-
*/
|
|
211
|
-
scheduleSave(): void {
|
|
212
|
-
const { provider, enabled } = this.deps.getPersistence();
|
|
213
|
-
if (!enabled || !provider) return;
|
|
214
|
-
if (this._saveTimer != null) return; // a save is already pending — coalesce
|
|
215
|
-
const elapsed = Date.now() - this._lastSaveAt;
|
|
216
|
-
const delay = Math.max(0, provider.saveDebounceMs - elapsed);
|
|
217
|
-
this._saveTimer = setTimeout(() => {
|
|
218
|
-
this._saveTimer = undefined;
|
|
219
|
-
this._lastSaveAt = Date.now();
|
|
220
|
-
void this.persistSession();
|
|
221
|
-
}, delay);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
/**
|
|
225
|
-
* Cancel a pending debounced autosave **without flushing it** (GENC-1351 §5.12).
|
|
226
|
-
* Called before a session switch/re-key so the outgoing transcript can never be
|
|
227
|
-
* written to the incoming session's file after the key changes — the clobber
|
|
228
|
-
* guard for in-place project switching.
|
|
229
|
-
*/
|
|
230
|
-
cancelPendingSave(): void {
|
|
231
|
-
if (this._saveTimer != null) {
|
|
232
|
-
clearTimeout(this._saveTimer);
|
|
233
|
-
this._saveTimer = undefined;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
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
|
-
|
|
323
|
-
/**
|
|
324
|
-
* Capture the current session to durable storage (GENC-1351 §5.1). Reads the
|
|
325
|
-
* full transcript from the driver and the restorable scalars from the store, and
|
|
326
|
-
* captures the active stateful agent's `serialize()` into `agentSnapshots`.
|
|
327
|
-
*/
|
|
328
|
-
private async persistSession(): Promise<void> {
|
|
329
|
-
const { provider, enabled } = this.deps.getPersistence();
|
|
330
|
-
if (!enabled || !provider) return;
|
|
331
|
-
const key = this.stateKey;
|
|
332
|
-
const ref = this.deps.getStore();
|
|
333
|
-
const driver = this.deps.getDriver();
|
|
334
|
-
if (!ref) return;
|
|
335
|
-
const s = ref.store.aiAssistant;
|
|
336
|
-
// Strip the display-only restore notices (restored / couldn't-restore) so they
|
|
337
|
-
// never persist or accumulate across reload→save→reload cycles.
|
|
338
|
-
const messages = (driver?.getRawHistory?.() ?? s.messages).filter(
|
|
339
|
-
(m) => !isSessionNoticeMarker(m),
|
|
340
|
-
);
|
|
341
|
-
if (!messages.length) return; // nothing worth persisting (e.g. a just-cleared session)
|
|
342
|
-
// Serialize the live active agent from the DRIVER (the instance whose
|
|
343
|
-
// `onActivate` ran), not via the host's `agents` — a host whose `agents` getter
|
|
344
|
-
// mints fresh configs (the showcase) would otherwise hand us a never-activated
|
|
345
|
-
// closure whose `serialize()` returns undefined.
|
|
346
|
-
const liveSnapshots: Record<string, unknown> = {};
|
|
347
|
-
const liveActive = driver?.getActiveAgent?.();
|
|
348
|
-
if (liveActive?.serialize) {
|
|
349
|
-
try {
|
|
350
|
-
const snap = liveActive.serialize();
|
|
351
|
-
if (snap !== undefined) liveSnapshots[liveActive.name] = snap;
|
|
352
|
-
} catch (e) {
|
|
353
|
-
logger.error('Session save: agent serialize() failed:', e);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
// A post-restore autosave (triggered by the restore's own history change) runs
|
|
357
|
-
// BEFORE the pinned stateful agent re-activates, so the live capture is empty
|
|
358
|
-
// even though a flow is mid-stream. Writing `{}` would wipe the machine and
|
|
359
|
-
// reset the flow on the next reload — so carry the flow-owner's last-known-good
|
|
360
|
-
// image forward (GENC-1351 §6). Pure + unit-tested.
|
|
361
|
-
const { snapshots: agentSnapshots, cache } = reconcileAgentSnapshots(
|
|
362
|
-
liveSnapshots,
|
|
363
|
-
s.flowOwnerAgentName,
|
|
364
|
-
this._cache,
|
|
365
|
-
);
|
|
366
|
-
this._cache = cache;
|
|
367
|
-
|
|
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);
|
|
374
|
-
|
|
375
|
-
if (!resumable) {
|
|
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.
|
|
392
|
-
const { resumeMessage, resumePrompt } = resolveNonResumableNotice({
|
|
393
|
-
notResumableMessage: liveActive?.notResumableMessage,
|
|
394
|
-
flowActivationPrompt: s.flowActivationPrompt,
|
|
395
|
-
messages,
|
|
396
|
-
defaultMessage: DEFAULT_NOT_RESUMABLE_MESSAGE,
|
|
397
|
-
});
|
|
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
|
-
}
|
|
431
|
-
this.logMeta('persistence.suppressed', {
|
|
432
|
-
agent: liveActive?.name ?? null,
|
|
433
|
-
isFlowOwner: s.flowOwnerAgentName === liveActive?.name,
|
|
434
|
-
frozeAtMessages: frozen.messages.length,
|
|
435
|
-
custom: !!liveActive?.notResumableMessage,
|
|
436
|
-
});
|
|
437
|
-
try {
|
|
438
|
-
await provider.save(key, {
|
|
439
|
-
...frozen,
|
|
440
|
-
interrupted: { message: resumeMessage, prompt: resumePrompt },
|
|
441
|
-
});
|
|
442
|
-
this._persistedMessages = frozen.messages.length;
|
|
443
|
-
this._interruptedStamped = true;
|
|
444
|
-
this.recordSaveOutcome(true, '', frozen.savedAt);
|
|
445
|
-
logger.debug('Flow non-resumable — froze snapshot + stamped interrupted marker', { key });
|
|
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
|
-
});
|
|
470
|
-
logger.error('Session save (interrupted marker) failed:', e);
|
|
471
|
-
}
|
|
472
|
-
return;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// Resumable → normal save (this overwrites any prior `interrupted` marker, so a
|
|
476
|
-
// later non-resumable episode stamps afresh).
|
|
477
|
-
const snapshot = createPersistedSession({
|
|
478
|
-
sessionKey: key,
|
|
479
|
-
host: window.location.host,
|
|
480
|
-
messages: [...messages],
|
|
481
|
-
activeAgentName: s.activeAgent?.name,
|
|
482
|
-
pinnedAgentName: s.pinnedAgentName,
|
|
483
|
-
flowOwnerAgentName: s.flowOwnerAgentName,
|
|
484
|
-
sessionCostUsd: s.sessionUsage.costUsd,
|
|
485
|
-
contextTokens: s.contextTokens,
|
|
486
|
-
contextLimit: s.contextLimit,
|
|
487
|
-
activeModel: s.activeModel,
|
|
488
|
-
activeProviderName: s.activeProviderName,
|
|
489
|
-
agentSnapshots,
|
|
490
|
-
});
|
|
491
|
-
// Observability (GENC-1351): record what each autosave captured for stateful
|
|
492
|
-
// agents — `agents: []` here means no machine snapshot was taken (the bug to
|
|
493
|
-
// watch for: the active agent's `serialize()` returned nothing).
|
|
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 = {
|
|
503
|
-
messages: messages.length,
|
|
504
|
-
agents: Object.keys(agentSnapshots),
|
|
505
|
-
active: liveActive?.name ?? null,
|
|
506
|
-
hasSerialize: !!liveActive?.serialize,
|
|
507
|
-
agentSnapshots,
|
|
508
|
-
};
|
|
509
|
-
try {
|
|
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);
|
|
515
|
-
logger.debug('Session saved', { key, messages: messages.length });
|
|
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
|
-
});
|
|
543
|
-
logger.error('Session save failed:', e);
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* Page-reload restore (GENC-1351 §5.3). With a stored snapshot for this session,
|
|
549
|
-
* hydrate the store and reseed the driver so the conversation continues. No-op
|
|
550
|
-
* when disabled, absent, or version/host mismatched. An `interrupted` snapshot
|
|
551
|
-
* (§5.9) restores the conversation but shows a "start again" assistant message
|
|
552
|
-
* instead of rehydrating the mid-flight flow.
|
|
553
|
-
*/
|
|
554
|
-
async restore(): Promise<void> {
|
|
555
|
-
const { provider } = this.deps.getPersistence();
|
|
556
|
-
if (!provider) return;
|
|
557
|
-
const key = this.stateKey;
|
|
558
|
-
const driver = this.deps.getDriver();
|
|
559
|
-
|
|
560
|
-
// Bound a slow/hung provider so a stuck load can't brick startup. On timeout
|
|
561
|
-
// (or a load rejection) `withTimeout` throws — its late result is discarded by
|
|
562
|
-
// the race, so it can never clobber a now-live session (§6). Crucially, a throw
|
|
563
|
-
// is distinct from a resolved `null`: a throw means "there may have been a
|
|
564
|
-
// session but we couldn't fetch it" → surface a notice; `null` means "nothing
|
|
565
|
-
// stored" → start fresh silently.
|
|
566
|
-
let snapshot: PersistedSession | null;
|
|
567
|
-
try {
|
|
568
|
-
snapshot = await withTimeout(provider.load(key), RESTORE_TIMEOUT_MS);
|
|
569
|
-
} catch (e) {
|
|
570
|
-
logger.warn('Session restore: load failed or timed out — starting fresh', e);
|
|
571
|
-
this.showRestoreFailedNotice();
|
|
572
|
-
return;
|
|
573
|
-
}
|
|
574
|
-
if (!snapshot) return; // genuinely nothing stored → fresh session, no notice
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
if (!isRestorableSession(snapshot, { host: window.location.host })) {
|
|
578
|
-
// A saved snapshot existed but can't be used (schema bump / cross-host) —
|
|
579
|
-
// tell the user rather than silently dropping their conversation.
|
|
580
|
-
logger.debug('Session restore skipped: version/host mismatch', { key });
|
|
581
|
-
this.showRestoreFailedNotice();
|
|
582
|
-
return;
|
|
583
|
-
}
|
|
584
|
-
// Trim a trailing incomplete turn (orphaned tool call / placeholder / pending
|
|
585
|
-
// interaction) so a snapshot saved mid-loop restores to the last
|
|
586
|
-
// provider-valid boundary (§5.3 step 2).
|
|
587
|
-
const trimmed = trimToRestorableBoundary(snapshot.messages).filter(
|
|
588
|
-
(m) => !isSessionNoticeMarker(m),
|
|
589
|
-
);
|
|
590
|
-
const ref = this.deps.getStore();
|
|
591
|
-
if (!ref) return;
|
|
592
|
-
// Diagnostics watermark (§5.8): messages present at restore already live in a
|
|
593
|
-
// prior load's diagnostics stream — record their identities in the shared
|
|
594
|
-
// per-session cursors so this load's forward capture never re-appends them
|
|
595
|
-
// (only genuinely new activity streams). Shared, so the element view that
|
|
596
|
-
// never runs restore honours the same watermark.
|
|
597
|
-
getDiagnosticsCursors(key).restoredMsgKeys = new Set(
|
|
598
|
-
flattenSubAgentMessages(trimmed)
|
|
599
|
-
.map((e) => diagnosticsMessageKey(e as unknown as DiagnosticEntry))
|
|
600
|
-
.filter((k): k is string => k !== null),
|
|
601
|
-
);
|
|
602
|
-
// Prime each stateful agent's persisted machine/state image FIRST — before
|
|
603
|
-
// seeding Redux or driver history — so the image is waiting on the driver no
|
|
604
|
-
// matter when the agent re-activates. The flow-owner lock self-heals on
|
|
605
|
-
// activation (OrchestratingDriver.applyAgent re-sets it).
|
|
606
|
-
driver?.primeRestoredAgentState?.(snapshot.agentSnapshots);
|
|
607
|
-
// Seed the carry-forward cache so the immediate post-restore autosave (fired
|
|
608
|
-
// by the history change below, before any agent re-activates) preserves the
|
|
609
|
-
// machine snapshot instead of overwriting it with an empty one (GENC-1351).
|
|
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;
|
|
614
|
-
// Observability (GENC-1351): record exactly what the restore primed, so a
|
|
615
|
-
// post-reload debug-log export shows whether a stateful agent's snapshot
|
|
616
|
-
// actually came back (meta events reset on reload, so this is the only window
|
|
617
|
-
// onto the saved→loaded handoff).
|
|
618
|
-
this.logMeta('persistence.restored', {
|
|
619
|
-
agents: Object.keys(snapshot.agentSnapshots ?? {}),
|
|
620
|
-
agentSnapshots: snapshot.agentSnapshots,
|
|
621
|
-
pinnedAgentName: snapshot.pinnedAgentName,
|
|
622
|
-
flowOwnerAgentName: snapshot.flowOwnerAgentName,
|
|
623
|
-
});
|
|
624
|
-
// Loudly surface saved agent state that can't be rehydrated because the agent
|
|
625
|
-
// isn't in the current set — otherwise the flow silently fails to resume, a
|
|
626
|
-
// real footgun for hosts with dynamic agent arrays. Non-fatal: the transcript
|
|
627
|
-
// and any known agents still restore.
|
|
628
|
-
const orphanedAgents = unknownAgentSnapshots(snapshot, this.deps.getAgentNames());
|
|
629
|
-
if (orphanedAgents.length) {
|
|
630
|
-
logger.error(
|
|
631
|
-
`Session restore: saved state for agent(s) [${orphanedAgents.join(', ')}] cannot be ` +
|
|
632
|
-
`rehydrated — not in the configured agents, so their flow will not resume. ` +
|
|
633
|
-
`(The transcript and any known agents still restore.)`,
|
|
634
|
-
{ key },
|
|
635
|
-
);
|
|
636
|
-
this.logMeta('persistence.restore-orphan', {
|
|
637
|
-
orphanedAgents,
|
|
638
|
-
knownAgents: this.deps.getAgentNames(),
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
// Trailing rows appended to the restored transcript:
|
|
642
|
-
// • interrupted flow (§5.9) → the user's triggering message (when captured) followed
|
|
643
|
-
// by a real `assistant` "start again" message. Both are normal chat bubbles,
|
|
644
|
-
// persisted as part of the conversation, so the exchange reads naturally — the user
|
|
645
|
-
// sees what they asked and the assistant's reply explaining it didn't complete
|
|
646
|
-
// (instead of an assistant reply to an invisible message). The frozen snapshot never
|
|
647
|
-
// rehydrates the in-flight flow (its `agentSnapshots` are the last *resumable* state),
|
|
648
|
-
// so the triggering message is text only — none of the dropped work comes back.
|
|
649
|
-
// • otherwise → the subtle "restored" `system-event` divider (§10-O2),
|
|
650
|
-
// display-only and stripped again on the next save.
|
|
651
|
-
const trailerTs = new Date().toISOString();
|
|
652
|
-
let trailers: ChatMessage[];
|
|
653
|
-
if (snapshot.interrupted) {
|
|
654
|
-
trailers = [];
|
|
655
|
-
// Re-append the triggering user message UNLESS the frozen transcript already
|
|
656
|
-
// ends with it. The two freeze paths differ: an *existing* prior snapshot is
|
|
657
|
-
// older than the trigger (so the trigger is absent and must be re-added), but
|
|
658
|
-
// the *fresh-freeze fallback* keeps the transcript through the last user
|
|
659
|
-
// message (`keepThroughLastUserMessage`), so the trigger is already present —
|
|
660
|
-
// re-appending it there would duplicate the bubble (GENC-1351 §5.9).
|
|
661
|
-
const lastTrimmed = trimmed[trimmed.length - 1];
|
|
662
|
-
const promptAlreadyPresent =
|
|
663
|
-
lastTrimmed?.role === 'user' && lastTrimmed.content === snapshot.interrupted.prompt;
|
|
664
|
-
if (snapshot.interrupted.prompt && !promptAlreadyPresent) {
|
|
665
|
-
trailers.push({
|
|
666
|
-
role: 'user',
|
|
667
|
-
content: snapshot.interrupted.prompt,
|
|
668
|
-
timestamp: trailerTs,
|
|
669
|
-
} as ChatMessage);
|
|
670
|
-
}
|
|
671
|
-
trailers.push({
|
|
672
|
-
role: 'assistant',
|
|
673
|
-
content: snapshot.interrupted.message,
|
|
674
|
-
timestamp: trailerTs,
|
|
675
|
-
} as ChatMessage);
|
|
676
|
-
} else if (trimmed.length === 0) {
|
|
677
|
-
trailers = [];
|
|
678
|
-
} else {
|
|
679
|
-
trailers = [
|
|
680
|
-
{
|
|
681
|
-
role: 'system-event',
|
|
682
|
-
content: SESSION_RESTORED_LABEL,
|
|
683
|
-
timestamp: trailerTs,
|
|
684
|
-
} as ChatMessage,
|
|
685
|
-
];
|
|
686
|
-
}
|
|
687
|
-
const messages = [...trimmed, ...trailers];
|
|
688
|
-
ref.actions.aiAssistant.loadSession(toLoadSessionPayload({ ...snapshot, messages }));
|
|
689
|
-
driver?.loadHistory?.([...messages]);
|
|
690
|
-
// Interrupted flow (§5.9): pre-fill the composer with the prompt that started
|
|
691
|
-
// the flow so "send it again" is one keystroke, and record it for observability.
|
|
692
|
-
if (snapshot.interrupted) {
|
|
693
|
-
if (snapshot.interrupted.prompt) {
|
|
694
|
-
ref.actions.aiAssistant.setInputValue(snapshot.interrupted.prompt);
|
|
695
|
-
}
|
|
696
|
-
this.logMeta('persistence.not-resumable', {
|
|
697
|
-
hadPrompt: !!snapshot.interrupted.prompt,
|
|
698
|
-
messages: trimmed.length,
|
|
699
|
-
});
|
|
700
|
-
}
|
|
701
|
-
// Propagate the restored pin AND flow-owner lock to the DRIVER's routing (GENC-1351).
|
|
702
|
-
// `loadSession` only hydrates the store; the driver learns them via `setPinnedAgent`/
|
|
703
|
-
// `setFlowOwner`, which otherwise ran at `wireDriver` time — before restore, when the
|
|
704
|
-
// store values were still null. Without this the first post-restore message falls
|
|
705
|
-
// through to the classifier and re-routes away from the flow agent. The pin covers a
|
|
706
|
-
// user-pinned agent; the flow-owner lock covers a *classifier-routed* stateful flow
|
|
707
|
-
// (flow-owned but unpinned) — `resolvePinnedAgent` reads `flowOwnerAgentName ??
|
|
708
|
-
// pinnedAgentName`, so restoring both resumes the flow without re-classifying, and the
|
|
709
|
-
// driver's routing agrees with the restored (picker-locked) store state.
|
|
710
|
-
if (driver instanceof OrchestratingDriver) {
|
|
711
|
-
driver.setPinnedAgent(snapshot.pinnedAgentName ?? null);
|
|
712
|
-
driver.setFlowOwner(snapshot.flowOwnerAgentName ?? null);
|
|
713
|
-
}
|
|
714
|
-
logger.debug('Session restored', { key, messages: messages.length });
|
|
715
|
-
} catch (e) {
|
|
716
|
-
logger.error('Session restore failed:', e);
|
|
717
|
-
this.showRestoreFailedNotice();
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* Surface a non-fatal "couldn't restore" notice after a failed restore (load
|
|
723
|
-
* timeout/error, or an unusable snapshot) so a vanished conversation isn't a
|
|
724
|
-
* silent mystery on reload (GENC-1351 §6). Reuses the display-only `system-event`
|
|
725
|
-
* divider (like the "Restored…" one) — stripped before persist, so it never lands
|
|
726
|
-
* in the fresh session's saved history. `loadHistory` is safe here: this path only
|
|
727
|
-
* runs when nothing was restored, so the driver is empty.
|
|
728
|
-
*/
|
|
729
|
-
private showRestoreFailedNotice(): void {
|
|
730
|
-
this.deps.getDriver()?.loadHistory?.([
|
|
731
|
-
{
|
|
732
|
-
role: 'system-event',
|
|
733
|
-
content: SESSION_RESTORE_FAILED_LABEL,
|
|
734
|
-
timestamp: new Date().toISOString(),
|
|
735
|
-
} as ChatMessage,
|
|
736
|
-
]);
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/**
|
|
740
|
-
* Drop the restore-core snapshot (the "remember chat" toggle-off and the Clear
|
|
741
|
-
* action) and reset the carry-forward cache. Diagnostics + preferences are
|
|
742
|
-
* separate and untouched here.
|
|
743
|
-
*/
|
|
744
|
-
async clearRestoreCore(): Promise<void> {
|
|
745
|
-
this._cache = {};
|
|
746
|
-
this._persistedMessages = 0;
|
|
747
|
-
this._interruptedStamped = false;
|
|
748
|
-
const { provider } = this.deps.getPersistence();
|
|
749
|
-
const key = this.stateKey;
|
|
750
|
-
if (!provider) return;
|
|
751
|
-
try {
|
|
752
|
-
await provider.clear(key);
|
|
753
|
-
} catch (e) {
|
|
754
|
-
logger.error('Session clear failed:', e);
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
// ── Preferences (GENC-1351 §5.10 Phase 2) ──────────────────────────────────
|
|
759
|
-
// The persister owns the provider I/O for the stored preferences blob; the
|
|
760
|
-
// element keeps `gatherPreferences` + the `chatConfig`-defaults reconciler
|
|
761
|
-
// (`applyPreferenceBaseline`), which needs `this.chatConfig`.
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Load the remembered preferences blob (bounded by {@link RESTORE_TIMEOUT_MS}) into
|
|
765
|
-
* {@link storedPreferences}. A throw propagates to the caller (the element wraps it
|
|
766
|
-
* and falls back to config defaults); on success `null` = no provider / none stored.
|
|
767
|
-
*/
|
|
768
|
-
async loadPreferences(): Promise<void> {
|
|
769
|
-
const { provider } = this.deps.getPersistence();
|
|
770
|
-
if (!provider) {
|
|
771
|
-
this._storedPreferences = null;
|
|
772
|
-
return;
|
|
773
|
-
}
|
|
774
|
-
const prefs = await withTimeout(
|
|
775
|
-
Promise.resolve(provider.loadPreferences?.(this.stateKey)),
|
|
776
|
-
RESTORE_TIMEOUT_MS,
|
|
777
|
-
);
|
|
778
|
-
this._storedPreferences = prefs ?? null;
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Persist the preferences blob. Only ever called on a genuine user change, so it
|
|
783
|
-
* also records that (freezing the reconciler — shared per session) and keeps
|
|
784
|
-
* {@link storedPreferences} fresh. Fires **regardless of "remember chat"** — display
|
|
785
|
-
* choices are the user's — needing only a provider that implements `savePreferences`.
|
|
786
|
-
*/
|
|
787
|
-
savePreferences(prefs: SessionPreferences): void {
|
|
788
|
-
this._preferencesUserChanged = true;
|
|
789
|
-
this._storedPreferences = prefs;
|
|
790
|
-
const { provider } = this.deps.getPersistence();
|
|
791
|
-
if (!provider?.savePreferences) return;
|
|
792
|
-
void provider
|
|
793
|
-
.savePreferences(this.stateKey, prefs)
|
|
794
|
-
.catch((e) => logger.error('Persistence: savePreferences failed:', e));
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
// ── Diagnostics append (GENC-1351 §5.10 Phase 3) ───────────────────────────
|
|
798
|
-
// One append scheduler per session (was per-view). Entry *building* stays
|
|
799
|
-
// element-owned (`getDiagnosticEntries`); the cursor-diffing + append live here.
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Throttled diagnostics append (GENC-1351 §5.8). On each transcript change, if the
|
|
803
|
-
* provider persists diagnostics, coalesce and append the new activity since the
|
|
804
|
-
* last flush (coarser than the restore-core save — the log is heavy). Keyed on
|
|
805
|
-
* provider capability only, **independent of "remember chat"**. No-op without a
|
|
806
|
-
* diagnostics-capable provider, so it's free for other consumers.
|
|
807
|
-
*/
|
|
808
|
-
scheduleDiagnostics(): void {
|
|
809
|
-
const { provider } = this.deps.getPersistence();
|
|
810
|
-
if (!provider?.appendDiagnostics) return;
|
|
811
|
-
// Don't capture mid-restore: the watermark (restoredMsgKeys) is set during
|
|
812
|
-
// restore, and a flush before it lands would re-append the restored history.
|
|
813
|
-
// `restoring` is slice-backed (shared), so every view holds off (§5.8/§6).
|
|
814
|
-
if (this.deps.getStore()?.store.aiAssistant.restoring) return;
|
|
815
|
-
if (this._diagnosticsTimer != null) return; // coalesce
|
|
816
|
-
const debounce = provider.diagnosticsDebounceMs ?? DEFAULT_DIAGNOSTICS_DEBOUNCE_MS;
|
|
817
|
-
const delay = Math.max(0, debounce - (Date.now() - this._lastDiagnosticsAt));
|
|
818
|
-
this._diagnosticsTimer = setTimeout(() => {
|
|
819
|
-
this._diagnosticsTimer = undefined;
|
|
820
|
-
this._lastDiagnosticsAt = Date.now();
|
|
821
|
-
void this.flushDiagnostics();
|
|
822
|
-
}, delay);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
/**
|
|
826
|
-
* Append the diagnostic entries created since the last flush to the provider's
|
|
827
|
-
* forward-only stream. Best-effort — a failed write is logged and never touches
|
|
828
|
-
* the chat. (The cursors advance on collect, so a dropped batch isn't retried;
|
|
829
|
-
* acceptable for a forensic stream that isn't the source of truth.) Also called
|
|
830
|
-
* directly by the download path to land the current delta before reassembly.
|
|
831
|
-
*/
|
|
832
|
-
async flushDiagnostics(): Promise<void> {
|
|
833
|
-
const { provider } = this.deps.getPersistence();
|
|
834
|
-
if (!provider?.appendDiagnostics) return;
|
|
835
|
-
const delta = this.collectDiagnosticsDelta();
|
|
836
|
-
if (!delta.length) return;
|
|
837
|
-
try {
|
|
838
|
-
await provider.appendDiagnostics(this.stateKey, delta);
|
|
839
|
-
} catch (e) {
|
|
840
|
-
logger.error('Diagnostics append failed:', e);
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* The diagnostic entries not yet appended this load — the forward-capture delta
|
|
846
|
-
* (§5.8). Skips messages present at restore or already appended; emits each meta-event
|
|
847
|
-
* (by index) once and each turn (by turn-index) once unpriced plus once more when its
|
|
848
|
-
* per-call `usage` lands; appends a `meta-snapshot` only when the block changed.
|
|
849
|
-
* Advances the shared per-session cursors as it goes.
|
|
850
|
-
*
|
|
851
|
-
* TODO(GENC-1461, longer-term): this whole delta step exists only because the element *pulls* the
|
|
852
|
-
* ENTIRE debug log on every throttled flush (`getDiagnosticEntries()` returns the full timeline),
|
|
853
|
-
* so we must diff it against per-session cursors to append only the new tail — and messages, unlike
|
|
854
|
-
* events (monotonic `index`) and turns (`turnIndex`), have no stable positional cursor (compaction
|
|
855
|
-
* rewrites ranges, tool-call messages are annotated in place, synthetic messages are spliced in,
|
|
856
|
-
* restore reloads them), forcing a content-derived identity (`diagnosticsMessageKey`). That
|
|
857
|
-
* identity is inherently fragile: it was too coarse for the reasoning/answer/tool-call split and
|
|
858
|
-
* silently dropped co-timestamped messages (GENC-1461; fixed for now by adding `category`). The
|
|
859
|
-
* robust fix is to invert the flow — have the driver *push* each diagnostic entry exactly once at
|
|
860
|
-
* creation (an append-only event stream) instead of the element pulling-and-diffing — which removes
|
|
861
|
-
* the message-identity key and this dedup entirely. Bigger change; deferred.
|
|
862
|
-
*/
|
|
863
|
-
private collectDiagnosticsDelta(): DiagnosticEntry[] {
|
|
864
|
-
const cursors = getDiagnosticsCursors(this.stateKey);
|
|
865
|
-
const delta: DiagnosticEntry[] = [];
|
|
866
|
-
for (const entry of this.deps.getDiagnosticEntries()) {
|
|
867
|
-
if (entry.kind === 'message') {
|
|
868
|
-
const key = diagnosticsMessageKey(entry);
|
|
869
|
-
if (key !== null) {
|
|
870
|
-
if (cursors.restoredMsgKeys.has(key) || cursors.emittedMsgKeys.has(key)) continue;
|
|
871
|
-
cursors.emittedMsgKeys.add(key);
|
|
872
|
-
}
|
|
873
|
-
delta.push(entry);
|
|
874
|
-
} else if (entry.kind === 'turn') {
|
|
875
|
-
// A turn entry is created BEFORE its model call and priced when the response
|
|
876
|
-
// lands, so a flush that falls in between persists it unpriced. Emit it a second
|
|
877
|
-
// time once `usage` is there — keyed separately so it happens at most once per
|
|
878
|
-
// turn — and let `assembleDebugLog` collapse the pair onto the priced copy.
|
|
879
|
-
// Without this the stitched lifetime log silently loses per-call cost for any
|
|
880
|
-
// turn that outlived a flush, which is most of the slow ones.
|
|
881
|
-
const key = String(entry.turnIndex ?? '');
|
|
882
|
-
const emitKey = entry.usage ? `${key}::usage` : key;
|
|
883
|
-
if (cursors.emittedTurnKeys.has(emitKey)) continue;
|
|
884
|
-
cursors.emittedTurnKeys.add(emitKey);
|
|
885
|
-
delta.push(entry);
|
|
886
|
-
} else if (entry.kind === 'event') {
|
|
887
|
-
const index = typeof entry.index === 'number' ? entry.index : -1;
|
|
888
|
-
if (index <= cursors.lastEventIndex) continue;
|
|
889
|
-
cursors.lastEventIndex = index;
|
|
890
|
-
delta.push(entry);
|
|
891
|
-
} else {
|
|
892
|
-
// meta-snapshot — append only when the near-static config actually changed.
|
|
893
|
-
// Prefer the entry's `dedupSignature` (the config-only projection the element
|
|
894
|
-
// builds), so per-flush churn in the volatile fields (export timestamp,
|
|
895
|
-
// context tokens/cost, live debug snapshot) no longer forces a re-append of
|
|
896
|
-
// the bulky block every time (GENC-1351 §5.8 / #12). Falls back to the full
|
|
897
|
-
// `meta` for any entry that predates the signature.
|
|
898
|
-
const sig =
|
|
899
|
-
typeof entry.dedupSignature === 'string'
|
|
900
|
-
? entry.dedupSignature
|
|
901
|
-
: JSON.stringify(entry.meta);
|
|
902
|
-
if (sig === cursors.lastMetaJson) continue;
|
|
903
|
-
cursors.lastMetaJson = sig;
|
|
904
|
-
delta.push(entry);
|
|
905
|
-
}
|
|
906
|
-
}
|
|
907
|
-
return delta;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
/** Cancel any pending timers. Called on explicit session teardown. */
|
|
911
|
-
dispose(): void {
|
|
912
|
-
clearTimeout(this._saveTimer);
|
|
913
|
-
this._saveTimer = undefined;
|
|
914
|
-
clearTimeout(this._diagnosticsTimer);
|
|
915
|
-
this._diagnosticsTimer = undefined;
|
|
916
|
-
}
|
|
917
|
-
}
|