@agentuity/runtime 0.0.107 → 0.0.108

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/src/session.ts CHANGED
@@ -44,6 +44,127 @@ export function parseThreadData(raw: string | undefined): ParsedThreadData {
44
44
  export type ThreadEventName = 'destroyed';
45
45
  export type SessionEventName = 'completed';
46
46
 
47
+ /**
48
+ * Represents a merge operation for thread state.
49
+ * Used when state is modified without being loaded first.
50
+ */
51
+ export interface MergeOperation {
52
+ op: 'set' | 'delete' | 'clear' | 'push';
53
+ key?: string;
54
+ value?: unknown;
55
+ maxRecords?: number;
56
+ }
57
+
58
+ /**
59
+ * Async thread state storage with lazy loading.
60
+ *
61
+ * State is only fetched from storage when first accessed via a read operation.
62
+ * Write operations can be batched and sent as a merge command without loading.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * // Read triggers lazy load
67
+ * const count = await ctx.thread.state.get<number>('messageCount');
68
+ *
69
+ * // Write queues operation (may not trigger load)
70
+ * await ctx.thread.state.set('messageCount', (count ?? 0) + 1);
71
+ *
72
+ * // Check state status
73
+ * if (ctx.thread.state.dirty) {
74
+ * console.log('State has pending changes');
75
+ * }
76
+ * ```
77
+ */
78
+ export interface ThreadState {
79
+ /**
80
+ * Whether state has been loaded from storage.
81
+ * True when state has been fetched via a read operation.
82
+ */
83
+ readonly loaded: boolean;
84
+
85
+ /**
86
+ * Whether state has pending changes.
87
+ * True when there are queued writes (pending-writes state) or
88
+ * modifications after loading (loaded state with changes).
89
+ */
90
+ readonly dirty: boolean;
91
+
92
+ /**
93
+ * Get a value from thread state.
94
+ * Triggers lazy load if state hasn't been fetched yet.
95
+ */
96
+ get<T = unknown>(key: string): Promise<T | undefined>;
97
+
98
+ /**
99
+ * Set a value in thread state.
100
+ * If state hasn't been loaded, queues the operation for merge.
101
+ */
102
+ set<T = unknown>(key: string, value: T): Promise<void>;
103
+
104
+ /**
105
+ * Check if a key exists in thread state.
106
+ * Triggers lazy load if state hasn't been fetched yet.
107
+ */
108
+ has(key: string): Promise<boolean>;
109
+
110
+ /**
111
+ * Delete a key from thread state.
112
+ * If state hasn't been loaded, queues the operation for merge.
113
+ */
114
+ delete(key: string): Promise<void>;
115
+
116
+ /**
117
+ * Clear all thread state.
118
+ * If state hasn't been loaded, queues a clear operation for merge.
119
+ */
120
+ clear(): Promise<void>;
121
+
122
+ /**
123
+ * Get all entries as key-value pairs.
124
+ * Triggers lazy load if state hasn't been fetched yet.
125
+ */
126
+ entries<T = unknown>(): Promise<[string, T][]>;
127
+
128
+ /**
129
+ * Get all keys.
130
+ * Triggers lazy load if state hasn't been fetched yet.
131
+ */
132
+ keys(): Promise<string[]>;
133
+
134
+ /**
135
+ * Get all values.
136
+ * Triggers lazy load if state hasn't been fetched yet.
137
+ */
138
+ values<T = unknown>(): Promise<T[]>;
139
+
140
+ /**
141
+ * Get the number of entries in state.
142
+ * Triggers lazy load if state hasn't been fetched yet.
143
+ */
144
+ size(): Promise<number>;
145
+
146
+ /**
147
+ * Push a value to an array in thread state.
148
+ * If the key doesn't exist, creates a new array with the value.
149
+ * If state hasn't been loaded, queues the operation for efficient merge.
150
+ *
151
+ * @param key - The key of the array to push to
152
+ * @param value - The value to push
153
+ * @param maxRecords - Optional maximum number of records to keep (sliding window)
154
+ *
155
+ * @example
156
+ * ```typescript
157
+ * // Efficiently append messages without loading entire array
158
+ * await ctx.thread.state.push('messages', { role: 'user', content: 'Hello' });
159
+ * await ctx.thread.state.push('messages', { role: 'assistant', content: 'Hi!' });
160
+ *
161
+ * // Keep only the last 100 messages
162
+ * await ctx.thread.state.push('messages', newMessage, 100);
163
+ * ```
164
+ */
165
+ push<T = unknown>(key: string, value: T, maxRecords?: number): Promise<void>;
166
+ }
167
+
47
168
  type ThreadEventCallback<T extends Thread> = (
48
169
  eventName: 'destroyed',
49
170
  thread: T
@@ -70,9 +191,12 @@ type SessionEventCallback<T extends Session> = (
70
191
  * ctx.logger.info('Thread: %s', ctx.thread.id);
71
192
  *
72
193
  * // Store data in thread state (persists across sessions)
73
- * ctx.thread.state.set('conversationCount',
74
- * (ctx.thread.state.get('conversationCount') as number || 0) + 1
75
- * );
194
+ * const count = await ctx.thread.state.get<number>('conversationCount') ?? 0;
195
+ * await ctx.thread.state.set('conversationCount', count + 1);
196
+ *
197
+ * // Access metadata
198
+ * const meta = await ctx.thread.getMetadata();
199
+ * await ctx.thread.setMetadata({ ...meta, lastAccess: Date.now() });
76
200
  *
77
201
  * // Listen for thread destruction
78
202
  * ctx.thread.addEventListener('destroyed', (eventName, thread) => {
@@ -92,31 +216,40 @@ export interface Thread {
92
216
  id: string;
93
217
 
94
218
  /**
95
- * Thread-scoped state storage that persists across multiple sessions.
96
- * Use this to maintain conversation history or user preferences.
219
+ * Thread-scoped state storage with async lazy-loading.
220
+ * State is only fetched from storage when first accessed via a read operation.
97
221
  *
98
222
  * @example
99
223
  * ```typescript
100
- * // Store conversation count
101
- * ctx.thread.state.set('messageCount',
102
- * (ctx.thread.state.get('messageCount') as number || 0) + 1
103
- * );
224
+ * // Read triggers lazy load
225
+ * const count = await ctx.thread.state.get<number>('messageCount');
226
+ * // Write may queue operation without loading
227
+ * await ctx.thread.state.set('messageCount', (count ?? 0) + 1);
104
228
  * ```
105
229
  */
106
- state: Map<string, unknown>;
230
+ state: ThreadState;
107
231
 
108
232
  /**
109
- * Unencrypted metadata for filtering and querying threads.
110
- * Unlike state, metadata is stored as-is in the database with GIN indexes
111
- * for efficient filtering. Initialized to empty object, only persisted if non-empty.
233
+ * Get thread metadata (lazy-loaded).
234
+ * Unlike state, metadata is stored unencrypted for efficient filtering.
112
235
  *
113
236
  * @example
114
237
  * ```typescript
115
- * ctx.thread.metadata.userId = 'user123';
116
- * ctx.thread.metadata.department = 'sales';
238
+ * const meta = await ctx.thread.getMetadata();
239
+ * console.log(meta.userId);
117
240
  * ```
118
241
  */
119
- metadata: Record<string, unknown>;
242
+ getMetadata(): Promise<Record<string, unknown>>;
243
+
244
+ /**
245
+ * Set thread metadata (full replace).
246
+ *
247
+ * @example
248
+ * ```typescript
249
+ * await ctx.thread.setMetadata({ userId: 'user123', department: 'sales' });
250
+ * ```
251
+ */
252
+ setMetadata(metadata: Record<string, unknown>): Promise<void>;
120
253
 
121
254
  /**
122
255
  * Register an event listener for when the thread is destroyed.
@@ -163,15 +296,16 @@ export interface Thread {
163
296
  /**
164
297
  * Check if the thread has any data.
165
298
  * Returns true if thread state is empty (no data to save).
299
+ * This is async because it may need to check lazy-loaded state.
166
300
  *
167
301
  * @example
168
302
  * ```typescript
169
- * if (ctx.thread.empty()) {
303
+ * if (await ctx.thread.empty()) {
170
304
  * // Thread has no data, won't be persisted
171
305
  * }
172
306
  * ```
173
307
  */
174
- empty(): boolean;
308
+ empty(): Promise<boolean>;
175
309
  }
176
310
 
177
311
  /**
@@ -722,24 +856,285 @@ export class DefaultThreadIDProvider implements ThreadIDProvider {
722
856
  }
723
857
  }
724
858
 
725
- export class DefaultThread implements Thread {
859
+ type LazyStateStatus = 'idle' | 'pending-writes' | 'loaded';
860
+
861
+ type RestoreFn = () => Promise<{ state: Map<string, unknown>; metadata: Record<string, unknown> }>;
862
+
863
+ export class LazyThreadState implements ThreadState {
864
+ #status: LazyStateStatus = 'idle';
865
+ #state: Map<string, unknown> = new Map();
866
+ #pendingOperations: MergeOperation[] = [];
726
867
  #initialStateJson: string | undefined;
868
+ #restoreFn: RestoreFn;
869
+ #loadingPromise: Promise<void> | null = null;
870
+
871
+ constructor(restoreFn: RestoreFn) {
872
+ this.#restoreFn = restoreFn;
873
+ }
874
+
875
+ get loaded(): boolean {
876
+ return this.#status === 'loaded';
877
+ }
878
+
879
+ get dirty(): boolean {
880
+ if (this.#status === 'pending-writes') {
881
+ return this.#pendingOperations.length > 0;
882
+ }
883
+ if (this.#status === 'loaded') {
884
+ const currentJson = JSON.stringify(Object.fromEntries(this.#state));
885
+ return currentJson !== this.#initialStateJson;
886
+ }
887
+ return false;
888
+ }
889
+
890
+ private async ensureLoaded(): Promise<void> {
891
+ if (this.#status === 'loaded') {
892
+ return;
893
+ }
894
+
895
+ if (this.#loadingPromise) {
896
+ await this.#loadingPromise;
897
+ return;
898
+ }
899
+
900
+ this.#loadingPromise = (async () => {
901
+ try {
902
+ await this.doLoad();
903
+ } finally {
904
+ this.#loadingPromise = null;
905
+ }
906
+ })();
907
+
908
+ await this.#loadingPromise;
909
+ }
910
+
911
+ private async doLoad(): Promise<void> {
912
+ const { state } = await this.#restoreFn();
913
+
914
+ // Initialize state from restored data
915
+ this.#state = new Map(state);
916
+ this.#initialStateJson = JSON.stringify(Object.fromEntries(this.#state));
917
+
918
+ // Apply any pending operations
919
+ for (const op of this.#pendingOperations) {
920
+ switch (op.op) {
921
+ case 'clear':
922
+ this.#state.clear();
923
+ break;
924
+ case 'set':
925
+ if (op.key !== undefined) {
926
+ this.#state.set(op.key, op.value);
927
+ }
928
+ break;
929
+ case 'delete':
930
+ if (op.key !== undefined) {
931
+ this.#state.delete(op.key);
932
+ }
933
+ break;
934
+ case 'push':
935
+ if (op.key !== undefined) {
936
+ const existing = this.#state.get(op.key);
937
+ if (Array.isArray(existing)) {
938
+ existing.push(op.value);
939
+ // Apply maxRecords limit
940
+ if (op.maxRecords !== undefined && existing.length > op.maxRecords) {
941
+ existing.splice(0, existing.length - op.maxRecords);
942
+ }
943
+ } else if (existing === undefined) {
944
+ this.#state.set(op.key, [op.value]);
945
+ }
946
+ // If existing is non-array, silently skip (error would have been thrown if loaded)
947
+ }
948
+ break;
949
+ }
950
+ }
951
+
952
+ this.#pendingOperations = [];
953
+ this.#status = 'loaded';
954
+ }
955
+
956
+ async get<T = unknown>(key: string): Promise<T | undefined> {
957
+ await this.ensureLoaded();
958
+ return this.#state.get(key) as T | undefined;
959
+ }
960
+
961
+ async set<T = unknown>(key: string, value: T): Promise<void> {
962
+ if (this.#status === 'loaded') {
963
+ this.#state.set(key, value);
964
+ } else {
965
+ this.#pendingOperations.push({ op: 'set', key, value });
966
+ if (this.#status === 'idle') {
967
+ this.#status = 'pending-writes';
968
+ }
969
+ }
970
+ }
971
+
972
+ async has(key: string): Promise<boolean> {
973
+ await this.ensureLoaded();
974
+ return this.#state.has(key);
975
+ }
976
+
977
+ async delete(key: string): Promise<void> {
978
+ if (this.#status === 'loaded') {
979
+ this.#state.delete(key);
980
+ } else {
981
+ this.#pendingOperations.push({ op: 'delete', key });
982
+ if (this.#status === 'idle') {
983
+ this.#status = 'pending-writes';
984
+ }
985
+ }
986
+ }
987
+
988
+ async clear(): Promise<void> {
989
+ if (this.#status === 'loaded') {
990
+ this.#state.clear();
991
+ } else {
992
+ // Clear replaces all previous pending operations
993
+ this.#pendingOperations = [{ op: 'clear' }];
994
+ if (this.#status === 'idle') {
995
+ this.#status = 'pending-writes';
996
+ }
997
+ }
998
+ }
999
+
1000
+ async entries<T = unknown>(): Promise<[string, T][]> {
1001
+ await this.ensureLoaded();
1002
+ return Array.from(this.#state.entries()) as [string, T][];
1003
+ }
1004
+
1005
+ async keys(): Promise<string[]> {
1006
+ await this.ensureLoaded();
1007
+ return Array.from(this.#state.keys());
1008
+ }
1009
+
1010
+ async values<T = unknown>(): Promise<T[]> {
1011
+ await this.ensureLoaded();
1012
+ return Array.from(this.#state.values()) as T[];
1013
+ }
1014
+
1015
+ async size(): Promise<number> {
1016
+ await this.ensureLoaded();
1017
+ return this.#state.size;
1018
+ }
1019
+
1020
+ async push<T = unknown>(key: string, value: T, maxRecords?: number): Promise<void> {
1021
+ if (this.#status === 'loaded') {
1022
+ // When loaded, push to local array
1023
+ const existing = this.#state.get(key);
1024
+ if (Array.isArray(existing)) {
1025
+ existing.push(value);
1026
+ // Apply maxRecords limit
1027
+ if (maxRecords !== undefined && existing.length > maxRecords) {
1028
+ existing.splice(0, existing.length - maxRecords);
1029
+ }
1030
+ } else if (existing === undefined) {
1031
+ this.#state.set(key, [value]);
1032
+ } else {
1033
+ throw new Error(`Cannot push to non-array value at key "${key}"`);
1034
+ }
1035
+ } else {
1036
+ // Queue push operation for merge
1037
+ const op: MergeOperation = { op: 'push', key, value };
1038
+ if (maxRecords !== undefined) {
1039
+ op.maxRecords = maxRecords;
1040
+ }
1041
+ this.#pendingOperations.push(op);
1042
+ if (this.#status === 'idle') {
1043
+ this.#status = 'pending-writes';
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ /**
1049
+ * Get the current status for save logic
1050
+ * @internal
1051
+ */
1052
+ getStatus(): LazyStateStatus {
1053
+ return this.#status;
1054
+ }
1055
+
1056
+ /**
1057
+ * Get pending operations for merge command
1058
+ * @internal
1059
+ */
1060
+ getPendingOperations(): MergeOperation[] {
1061
+ return [...this.#pendingOperations];
1062
+ }
1063
+
1064
+ /**
1065
+ * Get serialized state for full save.
1066
+ * Ensures state is loaded before serializing.
1067
+ * @internal
1068
+ */
1069
+ async getSerializedState(): Promise<Record<string, unknown>> {
1070
+ await this.ensureLoaded();
1071
+ return Object.fromEntries(this.#state);
1072
+ }
1073
+ }
1074
+
1075
+ export class DefaultThread implements Thread {
727
1076
  readonly id: string;
728
- readonly state: Map<string, unknown>;
729
- metadata: Record<string, unknown>;
1077
+ readonly state: LazyThreadState;
1078
+ #metadata: Record<string, unknown> | null = null;
1079
+ #metadataDirty = false;
1080
+ #metadataLoadPromise: Promise<void> | null = null;
730
1081
  private provider: ThreadProvider;
1082
+ #restoreFn: RestoreFn;
1083
+ #restoredMetadata: Record<string, unknown> | undefined;
731
1084
 
732
1085
  constructor(
733
1086
  provider: ThreadProvider,
734
1087
  id: string,
735
- initialStateJson?: string,
736
- metadata?: Record<string, unknown>
1088
+ restoreFn: RestoreFn,
1089
+ initialMetadata?: Record<string, unknown>
737
1090
  ) {
738
1091
  this.provider = provider;
739
1092
  this.id = id;
740
- this.state = new Map();
741
- this.#initialStateJson = initialStateJson;
742
- this.metadata = metadata || {};
1093
+ this.#restoreFn = restoreFn;
1094
+ this.#restoredMetadata = initialMetadata;
1095
+ this.state = new LazyThreadState(restoreFn);
1096
+ }
1097
+
1098
+ private async ensureMetadataLoaded(): Promise<void> {
1099
+ if (this.#metadata !== null) {
1100
+ return;
1101
+ }
1102
+
1103
+ // If we have initial metadata from thread creation, use it
1104
+ if (this.#restoredMetadata !== undefined) {
1105
+ this.#metadata = this.#restoredMetadata;
1106
+ return;
1107
+ }
1108
+
1109
+ if (this.#metadataLoadPromise) {
1110
+ await this.#metadataLoadPromise;
1111
+ return;
1112
+ }
1113
+
1114
+ this.#metadataLoadPromise = (async () => {
1115
+ try {
1116
+ await this.doLoadMetadata();
1117
+ } finally {
1118
+ this.#metadataLoadPromise = null;
1119
+ }
1120
+ })();
1121
+
1122
+ await this.#metadataLoadPromise;
1123
+ }
1124
+
1125
+ private async doLoadMetadata(): Promise<void> {
1126
+ const { metadata } = await this.#restoreFn();
1127
+ this.#metadata = metadata;
1128
+ }
1129
+
1130
+ async getMetadata(): Promise<Record<string, unknown>> {
1131
+ await this.ensureMetadataLoaded();
1132
+ return { ...this.#metadata! };
1133
+ }
1134
+
1135
+ async setMetadata(metadata: Record<string, unknown>): Promise<void> {
1136
+ this.#metadata = metadata;
1137
+ this.#metadataDirty = true;
743
1138
  }
744
1139
 
745
1140
  addEventListener(eventName: ThreadEventName, callback: ThreadEventCallback<any>): void {
@@ -773,33 +1168,80 @@ export class DefaultThread implements Thread {
773
1168
  }
774
1169
 
775
1170
  /**
776
- * Check if thread state has been modified since restore
1171
+ * Check if thread has any data (state or metadata)
1172
+ */
1173
+ async empty(): Promise<boolean> {
1174
+ const stateSize = await this.state.size();
1175
+ // Check both loaded metadata and initial metadata from constructor
1176
+ const meta = this.#metadata ?? this.#restoredMetadata ?? {};
1177
+ return stateSize === 0 && Object.keys(meta).length === 0;
1178
+ }
1179
+
1180
+ /**
1181
+ * Check if thread needs saving
777
1182
  * @internal
778
1183
  */
779
- isDirty(): boolean {
780
- if (this.state.size === 0 && !this.#initialStateJson) {
781
- return false;
1184
+ needsSave(): boolean {
1185
+ return this.state.dirty || this.#metadataDirty;
1186
+ }
1187
+
1188
+ /**
1189
+ * Get the save mode for this thread
1190
+ * @internal
1191
+ */
1192
+ getSaveMode(): 'none' | 'merge' | 'full' {
1193
+ const stateStatus = this.state.getStatus();
1194
+
1195
+ if (stateStatus === 'idle' && !this.#metadataDirty) {
1196
+ return 'none';
782
1197
  }
783
1198
 
784
- const currentJson = JSON.stringify(Object.fromEntries(this.state));
1199
+ if (stateStatus === 'pending-writes') {
1200
+ return 'merge';
1201
+ }
1202
+
1203
+ if (stateStatus === 'loaded' && (this.state.dirty || this.#metadataDirty)) {
1204
+ return 'full';
1205
+ }
785
1206
 
786
- return currentJson !== this.#initialStateJson;
1207
+ // Only metadata was changed without loading state
1208
+ if (this.#metadataDirty) {
1209
+ return 'merge';
1210
+ }
1211
+
1212
+ return 'none';
787
1213
  }
788
1214
 
789
1215
  /**
790
- * Check if thread has any data (state or metadata)
1216
+ * Get pending operations for merge command
1217
+ * @internal
791
1218
  */
792
- empty(): boolean {
793
- return this.state.size === 0 && Object.keys(this.metadata).length === 0;
1219
+ getPendingOperations(): MergeOperation[] {
1220
+ return this.state.getPendingOperations();
794
1221
  }
795
1222
 
796
1223
  /**
797
- * Get serialized state for saving
1224
+ * Get metadata for saving (returns null if not loaded/modified)
798
1225
  * @internal
799
1226
  */
800
- getSerializedState(): string {
801
- const hasState = this.state.size > 0;
802
- const hasMetadata = Object.keys(this.metadata).length > 0;
1227
+ getMetadataForSave(): Record<string, unknown> | undefined {
1228
+ if (this.#metadataDirty && this.#metadata) {
1229
+ return this.#metadata;
1230
+ }
1231
+ return undefined;
1232
+ }
1233
+
1234
+ /**
1235
+ * Get serialized state for full save.
1236
+ * Ensures state is loaded before serializing.
1237
+ * @internal
1238
+ */
1239
+ async getSerializedState(): Promise<string> {
1240
+ const state = await this.state.getSerializedState();
1241
+ // Also ensure metadata is loaded
1242
+ const meta = this.#metadata ?? this.#restoredMetadata ?? {};
1243
+ const hasState = Object.keys(state).length > 0;
1244
+ const hasMetadata = Object.keys(meta).length > 0;
803
1245
 
804
1246
  if (!hasState && !hasMetadata) {
805
1247
  return '';
@@ -808,11 +1250,11 @@ export class DefaultThread implements Thread {
808
1250
  const data: { state?: Record<string, unknown>; metadata?: Record<string, unknown> } = {};
809
1251
 
810
1252
  if (hasState) {
811
- data.state = Object.fromEntries(this.state);
1253
+ data.state = state;
812
1254
  }
813
1255
 
814
1256
  if (hasMetadata) {
815
- data.metadata = this.metadata;
1257
+ data.metadata = meta;
816
1258
  }
817
1259
 
818
1260
  return JSON.stringify(data);
@@ -1224,6 +1666,58 @@ export class ThreadWebSocketClient {
1224
1666
  });
1225
1667
  }
1226
1668
 
1669
+ async merge(
1670
+ threadId: string,
1671
+ operations: MergeOperation[],
1672
+ metadata?: Record<string, unknown>
1673
+ ): Promise<void> {
1674
+ // Wait for connection/reconnection if in progress
1675
+ if (this.wsConnecting) {
1676
+ await this.wsConnecting;
1677
+ }
1678
+
1679
+ if (!this.authenticated || !this.ws) {
1680
+ throw new Error('WebSocket not connected or authenticated');
1681
+ }
1682
+
1683
+ return new Promise((resolve, reject) => {
1684
+ const requestId = crypto.randomUUID();
1685
+ this.pendingRequests.set(requestId, {
1686
+ resolve: () => resolve(),
1687
+ reject,
1688
+ });
1689
+
1690
+ const data: {
1691
+ thread_id: string;
1692
+ operations: MergeOperation[];
1693
+ metadata?: Record<string, unknown>;
1694
+ } = {
1695
+ thread_id: threadId,
1696
+ operations,
1697
+ };
1698
+
1699
+ if (metadata && Object.keys(metadata).length > 0) {
1700
+ data.metadata = metadata;
1701
+ }
1702
+
1703
+ const message = {
1704
+ id: requestId,
1705
+ action: 'merge',
1706
+ data,
1707
+ };
1708
+
1709
+ this.ws!.send(JSON.stringify(message));
1710
+
1711
+ // Timeout after configured duration
1712
+ setTimeout(() => {
1713
+ if (this.pendingRequests.has(requestId)) {
1714
+ this.pendingRequests.delete(requestId);
1715
+ reject(new Error('Request timeout'));
1716
+ }
1717
+ }, this.requestTimeoutMs);
1718
+ });
1719
+ }
1720
+
1227
1721
  cleanup(): void {
1228
1722
  // Mark as disposed to prevent new reconnection attempts
1229
1723
  this.isDisposed = true;
@@ -1287,90 +1781,105 @@ export class DefaultThreadProvider implements ThreadProvider {
1287
1781
  async restore(ctx: Context<Env>): Promise<Thread> {
1288
1782
  const threadId = await this.threadIDProvider!.getThreadId(this.appState!, ctx);
1289
1783
  validateThreadIdOrThrow(threadId);
1290
- internal.info('[thread] restoring thread %s', threadId);
1784
+ internal.info('[thread] creating lazy thread %s (no eager restore)', threadId);
1291
1785
 
1292
- // Wait for WebSocket connection if still connecting
1293
- if (this.wsConnecting) {
1294
- internal.info('[thread] waiting for WebSocket connection');
1295
- await this.wsConnecting;
1296
- }
1786
+ // Create a restore function that will be called lazily when state/metadata is accessed
1787
+ const restoreFn = async (): Promise<{
1788
+ state: Map<string, unknown>;
1789
+ metadata: Record<string, unknown>;
1790
+ }> => {
1791
+ internal.info('[thread] lazy loading state for thread %s', threadId);
1792
+
1793
+ // Wait for WebSocket connection if still connecting
1794
+ if (this.wsConnecting) {
1795
+ internal.info('[thread] waiting for WebSocket connection');
1796
+ await this.wsConnecting;
1797
+ }
1798
+
1799
+ if (!this.wsClient) {
1800
+ internal.info('[thread] no WebSocket client available, returning empty state');
1801
+ return { state: new Map(), metadata: {} };
1802
+ }
1297
1803
 
1298
- // Restore thread state and metadata from WebSocket if available
1299
- let initialStateJson: string | undefined;
1300
- let restoredMetadata: Record<string, unknown> | undefined;
1301
- if (this.wsClient) {
1302
1804
  try {
1303
- internal.info('[thread] restoring state from WebSocket');
1304
1805
  const restoredData = await this.wsClient.restore(threadId);
1305
1806
  if (restoredData) {
1306
1807
  internal.info('[thread] restored state: %d bytes', restoredData.length);
1307
1808
  const { flatStateJson, metadata } = parseThreadData(restoredData);
1308
- initialStateJson = flatStateJson;
1309
- restoredMetadata = metadata;
1310
- } else {
1311
- internal.info('[thread] no existing state found');
1312
- }
1313
- } catch (err) {
1314
- internal.info('[thread] WebSocket restore failed: %s', err);
1315
- // Continue with empty state rather than failing
1316
- }
1317
- } else {
1318
- internal.info('[thread] no WebSocket client available');
1319
- }
1320
1809
 
1321
- const thread = new DefaultThread(this, threadId, initialStateJson, restoredMetadata);
1810
+ const state = new Map<string, unknown>();
1811
+ if (flatStateJson) {
1812
+ try {
1813
+ const data = JSON.parse(flatStateJson);
1814
+ for (const [key, value] of Object.entries(data)) {
1815
+ state.set(key, value);
1816
+ }
1817
+ } catch {
1818
+ internal.info('[thread] failed to parse state JSON');
1819
+ }
1820
+ }
1322
1821
 
1323
- // Populate thread state from restored data
1324
- if (initialStateJson) {
1325
- try {
1326
- const data = JSON.parse(initialStateJson);
1327
- for (const [key, value] of Object.entries(data)) {
1328
- thread.state.set(key, value);
1822
+ return { state, metadata: metadata || {} };
1329
1823
  }
1330
- internal.info('[thread] populated state with %d keys', thread.state.size);
1824
+ internal.info('[thread] no existing state found');
1825
+ return { state: new Map(), metadata: {} };
1331
1826
  } catch (err) {
1332
- internal.info('[thread] failed to parse state JSON: %s', err);
1333
- // Continue with empty state if parsing fails
1827
+ internal.info('[thread] WebSocket restore failed: %s', err);
1828
+ return { state: new Map(), metadata: {} };
1334
1829
  }
1335
- }
1830
+ };
1336
1831
 
1832
+ const thread = new DefaultThread(this, threadId, restoreFn);
1337
1833
  await fireEvent('thread.created', thread);
1338
1834
  return thread;
1339
1835
  }
1340
1836
 
1341
1837
  async save(thread: Thread): Promise<void> {
1342
1838
  if (thread instanceof DefaultThread) {
1839
+ const saveMode = thread.getSaveMode();
1343
1840
  internal.info(
1344
- '[thread] DefaultThreadProvider.save() - thread %s, isDirty: %s, hasWsClient: %s',
1841
+ '[thread] DefaultThreadProvider.save() - thread %s, saveMode: %s, hasWsClient: %s',
1345
1842
  thread.id,
1346
- thread.isDirty(),
1843
+ saveMode,
1347
1844
  !!this.wsClient
1348
1845
  );
1349
1846
 
1847
+ if (saveMode === 'none') {
1848
+ internal.info('[thread] skipping save - no changes');
1849
+ return;
1850
+ }
1851
+
1350
1852
  // Wait for WebSocket connection if still connecting
1351
1853
  if (this.wsConnecting) {
1352
1854
  internal.info('[thread] waiting for WebSocket connection');
1353
1855
  await this.wsConnecting;
1354
1856
  }
1355
1857
 
1356
- // Only save to WebSocket if state has changed
1357
- if (this.wsClient && thread.isDirty()) {
1358
- try {
1359
- const serialized = thread.getSerializedState();
1858
+ if (!this.wsClient) {
1859
+ internal.info('[thread] no WebSocket client available, skipping save');
1860
+ return;
1861
+ }
1862
+
1863
+ try {
1864
+ if (saveMode === 'merge') {
1865
+ const operations = thread.getPendingOperations();
1866
+ const metadata = thread.getMetadataForSave();
1360
1867
  internal.info(
1361
- '[thread] saving to WebSocket, serialized length: %d',
1362
- serialized.length
1868
+ '[thread] sending merge command with %d operations',
1869
+ operations.length
1363
1870
  );
1364
- const metadata =
1365
- Object.keys(thread.metadata).length > 0 ? thread.metadata : undefined;
1871
+ await this.wsClient.merge(thread.id, operations, metadata);
1872
+ internal.info('[thread] WebSocket merge completed');
1873
+ } else if (saveMode === 'full') {
1874
+ const serialized = await thread.getSerializedState();
1875
+ internal.info('[thread] saving to WebSocket, serialized length: %d', serialized.length);
1876
+ const metadata = thread.getMetadataForSave();
1366
1877
  await this.wsClient.save(thread.id, serialized, metadata);
1367
1878
  internal.info('[thread] WebSocket save completed');
1368
- } catch (err) {
1369
- internal.info('[thread] WebSocket save failed: %s', err);
1370
- // Don't throw - allow request to complete even if save fails
1371
1879
  }
1372
- } else {
1373
- internal.info('[thread] skipping save - no wsClient or thread not dirty');
1880
+ } catch (err) {
1881
+ internal.info('[thread] WebSocket save/merge failed: %s', err);
1882
+ // Don't throw - allow request to complete even if save fails
1374
1883
  }
1375
1884
  }
1376
1885
  }