@assistant-ui/core 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/dist/adapters/attachment.d.ts +4 -0
  2. package/dist/adapters/attachment.d.ts.map +1 -1
  3. package/dist/adapters/attachment.js +1 -1
  4. package/dist/adapters/attachment.js.map +1 -1
  5. package/dist/adapters/index.d.ts +10 -0
  6. package/dist/adapters/index.d.ts.map +1 -0
  7. package/dist/adapters/index.js +4 -0
  8. package/dist/adapters/index.js.map +1 -0
  9. package/dist/adapters/mention.d.ts +24 -0
  10. package/dist/adapters/mention.d.ts.map +1 -0
  11. package/dist/adapters/mention.js +42 -0
  12. package/dist/adapters/mention.js.map +1 -0
  13. package/dist/index.d.ts +3 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/react/RuntimeAdapter.js +5 -6
  18. package/dist/react/RuntimeAdapter.js.map +1 -1
  19. package/dist/react/client/Interactables.d.ts +3 -0
  20. package/dist/react/client/Interactables.d.ts.map +1 -0
  21. package/dist/react/client/Interactables.js +173 -0
  22. package/dist/react/client/Interactables.js.map +1 -0
  23. package/dist/react/client/Tools.js +5 -6
  24. package/dist/react/client/Tools.js.map +1 -1
  25. package/dist/react/index.d.ts +6 -0
  26. package/dist/react/index.d.ts.map +1 -1
  27. package/dist/react/index.js +5 -0
  28. package/dist/react/index.js.map +1 -1
  29. package/dist/react/model-context/makeInteractable.d.ts +10 -0
  30. package/dist/react/model-context/makeInteractable.d.ts.map +1 -0
  31. package/dist/react/model-context/makeInteractable.js +10 -0
  32. package/dist/react/model-context/makeInteractable.js.map +1 -0
  33. package/dist/react/model-context/useInteractable.d.ts +16 -0
  34. package/dist/react/model-context/useInteractable.d.ts.map +1 -0
  35. package/dist/react/model-context/useInteractable.js +36 -0
  36. package/dist/react/model-context/useInteractable.js.map +1 -0
  37. package/dist/react/primitive-hooks/useComposerSend.d.ts +2 -1
  38. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  39. package/dist/react/primitive-hooks/useComposerSend.js +5 -3
  40. package/dist/react/primitive-hooks/useComposerSend.js.map +1 -1
  41. package/dist/react/primitives/composer/ComposerQueue.d.ts +31 -0
  42. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -0
  43. package/dist/react/primitives/composer/ComposerQueue.js +30 -0
  44. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -0
  45. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  46. package/dist/react/primitives/message/MessageParts.js +2 -0
  47. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  48. package/dist/react/providers/QueueItemByIndexProvider.d.ts +6 -0
  49. package/dist/react/providers/QueueItemByIndexProvider.d.ts.map +1 -0
  50. package/dist/react/providers/QueueItemByIndexProvider.js +13 -0
  51. package/dist/react/providers/QueueItemByIndexProvider.js.map +1 -0
  52. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  53. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +1 -0
  54. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  55. package/dist/react/types/scopes/interactables.d.ts +39 -0
  56. package/dist/react/types/scopes/interactables.d.ts.map +1 -0
  57. package/dist/react/types/scopes/interactables.js +2 -0
  58. package/dist/react/types/scopes/interactables.js.map +1 -0
  59. package/dist/react/types/store-augmentation.d.ts +2 -0
  60. package/dist/react/types/store-augmentation.d.ts.map +1 -1
  61. package/dist/runtime/base/base-composer-runtime-core.d.ts +1 -1
  62. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  63. package/dist/runtime/base/base-composer-runtime-core.js +33 -8
  64. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  65. package/dist/runtime/interfaces/composer-runtime-core.d.ts +1 -1
  66. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  67. package/dist/runtime/interfaces/thread-runtime-core.d.ts +1 -0
  68. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  69. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  70. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  71. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  72. package/dist/runtimes/local/local-thread-runtime-core.d.ts +1 -0
  73. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  74. package/dist/runtimes/local/local-thread-runtime-core.js +1 -0
  75. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  76. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -0
  77. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  78. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -0
  79. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  80. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  81. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -0
  82. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  83. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +9 -0
  84. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  85. package/dist/runtimes/remote-thread-list/optimistic-state.js +20 -0
  86. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  87. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  88. package/dist/store/clients/no-op-composer-client.js +4 -0
  89. package/dist/store/clients/no-op-composer-client.js.map +1 -1
  90. package/dist/store/clients/runtime-adapter.d.ts +1 -1
  91. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  92. package/dist/store/clients/runtime-adapter.js +19 -26
  93. package/dist/store/clients/runtime-adapter.js.map +1 -1
  94. package/dist/store/index.d.ts +2 -1
  95. package/dist/store/index.d.ts.map +1 -1
  96. package/dist/store/index.js.map +1 -1
  97. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  98. package/dist/store/runtime-clients/composer-runtime-client.js +16 -5
  99. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  100. package/dist/store/scope-registration.d.ts +2 -0
  101. package/dist/store/scope-registration.d.ts.map +1 -1
  102. package/dist/store/scopes/composer.d.ts +25 -1
  103. package/dist/store/scopes/composer.d.ts.map +1 -1
  104. package/dist/store/scopes/queue-item.d.ts +20 -0
  105. package/dist/store/scopes/queue-item.d.ts.map +1 -0
  106. package/dist/store/scopes/queue-item.js +2 -0
  107. package/dist/store/scopes/queue-item.js.map +1 -0
  108. package/dist/types/index.d.ts +6 -0
  109. package/dist/types/index.d.ts.map +1 -0
  110. package/dist/types/index.js +2 -0
  111. package/dist/types/index.js.map +1 -0
  112. package/dist/types/mention.d.ts +32 -0
  113. package/dist/types/mention.d.ts.map +1 -0
  114. package/dist/types/mention.js +2 -0
  115. package/dist/types/mention.js.map +1 -0
  116. package/package.json +11 -11
  117. package/src/adapters/attachment.ts +1 -1
  118. package/src/adapters/index.ts +34 -0
  119. package/src/adapters/mention.ts +77 -0
  120. package/src/index.ts +11 -0
  121. package/src/react/RuntimeAdapter.ts +5 -7
  122. package/src/react/client/Interactables.ts +233 -0
  123. package/src/react/client/Tools.ts +5 -6
  124. package/src/react/index.ts +24 -0
  125. package/src/react/model-context/makeInteractable.ts +21 -0
  126. package/src/react/model-context/useInteractable.ts +73 -0
  127. package/src/react/primitive-hooks/useComposerSend.ts +11 -4
  128. package/src/react/primitives/composer/ComposerQueue.tsx +58 -0
  129. package/src/react/primitives/message/MessageParts.tsx +2 -0
  130. package/src/react/providers/QueueItemByIndexProvider.tsx +21 -0
  131. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +1 -0
  132. package/src/react/types/scopes/interactables.ts +44 -0
  133. package/src/react/types/store-augmentation.ts +2 -0
  134. package/src/runtime/base/base-composer-runtime-core.ts +45 -9
  135. package/src/runtime/interfaces/composer-runtime-core.ts +4 -1
  136. package/src/runtime/interfaces/thread-runtime-core.ts +1 -0
  137. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +2 -0
  138. package/src/runtimes/local/local-thread-runtime-core.ts +1 -0
  139. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -0
  140. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -0
  141. package/src/runtimes/remote-thread-list/optimistic-state.ts +27 -0
  142. package/src/store/clients/no-op-composer-client.ts +4 -0
  143. package/src/store/clients/runtime-adapter.ts +20 -31
  144. package/src/store/index.ts +7 -0
  145. package/src/store/runtime-clients/composer-runtime-client.ts +22 -7
  146. package/src/store/scope-registration.ts +2 -0
  147. package/src/store/scopes/composer.ts +26 -1
  148. package/src/store/scopes/queue-item.ts +20 -0
  149. package/src/tests/OptimisticState-list-race.test.ts +256 -0
  150. package/src/tests/mention-formatter.test.ts +112 -0
  151. package/src/types/index.ts +47 -0
  152. package/src/types/mention.ts +50 -0
@@ -65,6 +65,7 @@ export class ExternalStoreThreadRuntimeCore
65
65
  dictation: false,
66
66
  attachments: false,
67
67
  feedback: false,
68
+ queue: false,
68
69
  };
69
70
 
70
71
  public get capabilities() {
@@ -139,6 +140,7 @@ export class ExternalStoreThreadRuntimeCore
139
140
  unstable_copy: this._store.unstable_capabilities?.copy !== false,
140
141
  attachments: !!this._store.adapters?.attachments,
141
142
  feedback: !!this._store.adapters?.feedback,
143
+ queue: false,
142
144
  };
143
145
  if (!shallowEqual(this._capabilities, newCapabilities)) {
144
146
  this._capabilities = newCapabilities;
@@ -47,6 +47,7 @@ export class LocalThreadRuntimeCore
47
47
  dictation: false,
48
48
  attachments: false,
49
49
  feedback: false,
50
+ queue: false,
50
51
  };
51
52
 
52
53
  private abortController: AbortController | null = null;
@@ -191,6 +191,7 @@ export class ReadonlyThreadRuntimeCore
191
191
  dictation: false,
192
192
  attachments: false,
193
193
  feedback: false,
194
+ queue: false,
194
195
  } as const;
195
196
 
196
197
  isDisabled = false;
@@ -165,6 +165,7 @@ export const EMPTY_THREAD_CORE: ThreadRuntimeCore = {
165
165
  dictation: false,
166
166
  attachments: false,
167
167
  feedback: false,
168
+ queue: false,
168
169
  },
169
170
 
170
171
  isDisabled: false,
@@ -30,6 +30,17 @@ const pipeTransforms = <TState, TExtra>(
30
30
  export class OptimisticState<TState> extends BaseSubscribable {
31
31
  private readonly _pendingTransforms: Array<PendingTransform<TState, any>> =
32
32
  [];
33
+
34
+ /**
35
+ * `optimistic` callbacks from transforms that have already resolved.
36
+ * Re-applied after every `then` callback so that a wholesale state
37
+ * replacement (e.g. list()) cannot erase earlier completed effects
38
+ * (e.g. delete). Cleared when no pending transforms remain.
39
+ *
40
+ * Correctness requirement: `optimistic` callbacks must be idempotent.
41
+ */
42
+ private readonly _completedOptimistics: Array<(state: TState) => TState> = [];
43
+
33
44
  private _baseValue: TState;
34
45
  private _cachedValue: TState;
35
46
 
@@ -77,12 +88,28 @@ export class OptimisticState<TState> extends BaseSubscribable {
77
88
  transform.optimistic,
78
89
  transform.then,
79
90
  ]);
91
+
92
+ // Re-apply previously completed optimistic callbacks so that a
93
+ // then() that does wholesale replacement cannot erase their effects.
94
+ for (const fn of this._completedOptimistics) {
95
+ this._baseValue = fn(this._baseValue);
96
+ }
97
+
98
+ if (transform.optimistic) {
99
+ this._completedOptimistics.push(transform.optimistic);
100
+ }
101
+
80
102
  return result;
81
103
  } finally {
82
104
  const index = this._pendingTransforms.indexOf(pendingTransform);
83
105
  if (index > -1) {
84
106
  this._pendingTransforms.splice(index, 1);
85
107
  }
108
+
109
+ if (this._pendingTransforms.length === 0) {
110
+ this._completedOptimistics.length = 0;
111
+ }
112
+
86
113
  this._updateState();
87
114
  }
88
115
  }
@@ -17,6 +17,7 @@ export const NoOpComposerClient = resource(
17
17
  type: type,
18
18
  dictation: undefined,
19
19
  quote: undefined,
20
+ queue: [],
20
21
  };
21
22
  }, [type]);
22
23
 
@@ -61,6 +62,9 @@ export const NoOpComposerClient = resource(
61
62
  setQuote: () => {
62
63
  throw new Error("Not supported");
63
64
  },
65
+ queueItem: () => {
66
+ throw new Error("Not supported");
67
+ },
64
68
  };
65
69
  },
66
70
  );
@@ -30,38 +30,27 @@ export const RuntimeAdapterResource = resource((runtime: AssistantRuntime) => {
30
30
  export const baseRuntimeAdapterTransformScopes = (
31
31
  scopes: ScopesConfig,
32
32
  parent: AssistantClient,
33
- ): ScopesConfig => {
34
- const result = {
35
- ...scopes,
36
- thread:
37
- scopes.thread ??
38
- Derived({
39
- source: "threads",
40
- query: { type: "main" },
41
- get: (aui) => aui.threads().thread("main"),
42
- }),
43
- threadListItem:
44
- scopes.threadListItem ??
45
- Derived({
46
- source: "threads",
47
- query: { type: "main" },
48
- get: (aui) => aui.threads().item("main"),
49
- }),
50
- composer:
51
- scopes.composer ??
52
- Derived({
53
- source: "thread",
54
- query: {},
55
- get: (aui) => aui.threads().thread("main").composer(),
56
- }),
57
- };
33
+ ): void => {
34
+ scopes.thread ??= Derived({
35
+ source: "threads",
36
+ query: { type: "main" },
37
+ get: (aui) => aui.threads().thread("main"),
38
+ });
39
+ scopes.threadListItem ??= Derived({
40
+ source: "threads",
41
+ query: { type: "main" },
42
+ get: (aui) => aui.threads().item("main"),
43
+ });
44
+ scopes.composer ??= Derived({
45
+ source: "thread",
46
+ query: {},
47
+ get: (aui) => aui.threads().thread("main").composer(),
48
+ });
58
49
 
59
- if (!result.modelContext && parent.modelContext.source === null) {
60
- result.modelContext = ModelContext();
50
+ if (!scopes.modelContext && parent.modelContext.source === null) {
51
+ scopes.modelContext = ModelContext();
61
52
  }
62
- if (!result.suggestions && parent.suggestions.source === null) {
63
- result.suggestions = Suggestions();
53
+ if (!scopes.suggestions && parent.suggestions.source === null) {
54
+ scopes.suggestions = Suggestions();
64
55
  }
65
-
66
- return result;
67
56
  };
@@ -35,10 +35,17 @@ export type {
35
35
  export type {
36
36
  ComposerState,
37
37
  ComposerMethods,
38
+ ComposerSendOptions,
38
39
  ComposerMeta,
39
40
  ComposerEvents,
40
41
  ComposerClientSchema,
41
42
  } from "./scopes/composer";
43
+ export type {
44
+ QueueItemState,
45
+ QueueItemMethods,
46
+ QueueItemMeta,
47
+ QueueItemClientSchema,
48
+ } from "./scopes/queue-item";
42
49
  export type {
43
50
  AttachmentState,
44
51
  AttachmentMethods,
@@ -16,7 +16,6 @@ import {
16
16
  ComposerRuntime,
17
17
  EditComposerRuntime,
18
18
  } from "../../runtime/api/composer-runtime";
19
- import { ComposerRuntimeEventType } from "../../runtime/interfaces/composer-runtime-core";
20
19
  import { ComposerState } from "../scopes/composer";
21
20
  import { AttachmentRuntimeClient } from "./attachment-runtime-client";
22
21
  import { tapSubscribable } from "./tap-subscribable";
@@ -54,12 +53,7 @@ export const ComposerClient = resource(
54
53
  const unsubscribers: Unsubscribe[] = [];
55
54
 
56
55
  // Subscribe to composer events
57
- const composerEvents: ComposerRuntimeEventType[] = [
58
- "send",
59
- "attachmentAdd",
60
- ];
61
-
62
- for (const event of composerEvents) {
56
+ for (const event of ["send", "attachmentAdd"] as const) {
63
57
  const unsubscribe = runtime.unstable_on(event, () => {
64
58
  emit(`composer.${event}`, {
65
59
  threadId: threadIdRef.current,
@@ -69,6 +63,23 @@ export const ComposerClient = resource(
69
63
  unsubscribers.push(unsubscribe);
70
64
  }
71
65
 
66
+ // attachmentAddError carries the failed attachment ID
67
+ unsubscribers.push(
68
+ runtime.unstable_on("attachmentAddError", () => {
69
+ const errorAttachment = runtime
70
+ .getState()
71
+ .attachments.findLast(
72
+ (a) =>
73
+ a.status.type === "incomplete" && a.status.reason === "error",
74
+ );
75
+ emit("composer.attachmentAddError", {
76
+ threadId: threadIdRef.current,
77
+ ...(messageIdRef && { messageId: messageIdRef.current }),
78
+ ...(errorAttachment && { attachmentId: errorAttachment.id }),
79
+ });
80
+ }),
81
+ );
82
+
72
83
  return () => {
73
84
  for (const unsub of unsubscribers) unsub();
74
85
  };
@@ -101,6 +112,7 @@ export const ComposerClient = resource(
101
112
  type: runtimeState.type ?? "thread",
102
113
  dictation: runtimeState.dictation,
103
114
  quote: runtimeState.quote,
115
+ queue: [],
104
116
  };
105
117
  }, [runtimeState, attachments.state]);
106
118
 
@@ -129,6 +141,9 @@ export const ComposerClient = resource(
129
141
  return attachments.get(selector);
130
142
  }
131
143
  },
144
+ queueItem: () => {
145
+ throw new Error("Queue is not supported in this runtime");
146
+ },
132
147
  __internal_getRuntime: () => runtime,
133
148
  };
134
149
  },
@@ -9,6 +9,7 @@ import type { ModelContextClientSchema } from "./scopes/model-context";
9
9
  import type { SuggestionsClientSchema } from "./scopes/suggestions";
10
10
  import type { SuggestionClientSchema } from "./scopes/suggestion";
11
11
  import type { ChainOfThoughtClientSchema } from "./scopes/chain-of-thought";
12
+ import type { QueueItemClientSchema } from "./scopes/queue-item";
12
13
 
13
14
  declare module "@assistant-ui/store" {
14
15
  interface ScopeRegistry {
@@ -23,5 +24,6 @@ declare module "@assistant-ui/store" {
23
24
  suggestions: SuggestionsClientSchema;
24
25
  suggestion: SuggestionClientSchema;
25
26
  chainOfThought: ChainOfThoughtClientSchema;
27
+ queueItem: QueueItemClientSchema;
26
28
  }
27
29
  }
@@ -5,6 +5,15 @@ import type { RunConfig } from "../../types/message";
5
5
  import type { ComposerRuntime } from "../../runtime/api/composer-runtime";
6
6
  import type { DictationState } from "../../runtime/interfaces/composer-runtime-core";
7
7
  import type { AttachmentMethods } from "./attachment";
8
+ import type { QueueItemState, QueueItemMethods } from "./queue-item";
9
+
10
+ export type ComposerSendOptions = {
11
+ /**
12
+ * Whether to steer (interrupt the current run and process this message immediately).
13
+ * When false (default), the message is queued and processed in order.
14
+ */
15
+ steer?: boolean;
16
+ };
8
17
 
9
18
  export type ComposerState = {
10
19
  readonly text: string;
@@ -28,6 +37,12 @@ export type ComposerState = {
28
37
  * Undefined when no quote is set.
29
38
  */
30
39
  readonly quote: QuoteInfo | undefined;
40
+
41
+ /**
42
+ * The queue of messages waiting to be processed.
43
+ * Empty when no messages are queued.
44
+ */
45
+ readonly queue: readonly QueueItemState[];
31
46
  };
32
47
 
33
48
  export type ComposerMethods = {
@@ -39,7 +54,7 @@ export type ComposerMethods = {
39
54
  clearAttachments(): Promise<void>;
40
55
  attachment(selector: { index: number } | { id: string }): AttachmentMethods;
41
56
  reset(): Promise<void>;
42
- send(): void;
57
+ send(opts?: ComposerSendOptions): void;
43
58
  cancel(): void;
44
59
  beginEdit(): void;
45
60
 
@@ -59,6 +74,11 @@ export type ComposerMethods = {
59
74
  */
60
75
  setQuote(quote: QuoteInfo | undefined): void;
61
76
 
77
+ /**
78
+ * Access a queue item by index.
79
+ */
80
+ queueItem(selector: { index: number }): QueueItemMethods;
81
+
62
82
  __internal_getRuntime?(): ComposerRuntime;
63
83
  };
64
84
 
@@ -70,6 +90,11 @@ export type ComposerMeta = {
70
90
  export type ComposerEvents = {
71
91
  "composer.send": { threadId: string; messageId?: string };
72
92
  "composer.attachmentAdd": { threadId: string; messageId?: string };
93
+ "composer.attachmentAddError": {
94
+ threadId: string;
95
+ messageId?: string;
96
+ attachmentId?: string;
97
+ };
73
98
  };
74
99
 
75
100
  export type ComposerClientSchema = {
@@ -0,0 +1,20 @@
1
+ export type QueueItemState = {
2
+ readonly id: string;
3
+ readonly prompt: string;
4
+ };
5
+
6
+ export type QueueItemMethods = {
7
+ getState(): QueueItemState;
8
+ steer(): void;
9
+ remove(): void;
10
+ };
11
+
12
+ export type QueueItemMeta = {
13
+ source: "composer";
14
+ query: { index: number };
15
+ };
16
+
17
+ export type QueueItemClientSchema = {
18
+ methods: QueueItemMethods;
19
+ meta: QueueItemMeta;
20
+ };
@@ -0,0 +1,256 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { OptimisticState } from "../runtimes/remote-thread-list/optimistic-state";
3
+ import {
4
+ type RemoteThreadState,
5
+ type RemoteThreadData,
6
+ type THREAD_MAPPING_ID,
7
+ createThreadMappingId,
8
+ updateStatusReducer,
9
+ } from "../runtimes/remote-thread-list/remote-thread-state";
10
+
11
+ /**
12
+ * Reproduces the race condition where a stale list() response
13
+ * re-introduces a thread that was deleted/archived while the list
14
+ * was in flight.
15
+ *
16
+ * Root cause (before fix): OptimisticState's `then` callback could
17
+ * overwrite `_baseValue`, erasing effects from transforms that had
18
+ * already completed. The fix re-applies completed `optimistic`
19
+ * callbacks after every `then`.
20
+ */
21
+
22
+ const EMPTY_STATE: RemoteThreadState = {
23
+ isLoading: false,
24
+ newThreadId: undefined,
25
+ threadIds: [],
26
+ archivedThreadIds: [],
27
+ threadIdMap: {},
28
+ threadData: {},
29
+ };
30
+
31
+ type ListResult = {
32
+ threads: {
33
+ remoteId: string;
34
+ status: "regular" | "archived";
35
+ title?: string;
36
+ externalId?: string | undefined;
37
+ }[];
38
+ };
39
+
40
+ /** Simulates getLoadThreadsPromise's then callback (plain version, no workarounds). */
41
+ const applyListResult = (
42
+ state: RemoteThreadState,
43
+ l: ListResult,
44
+ ): RemoteThreadState => {
45
+ const newThreadIds: string[] = [];
46
+ const newArchivedThreadIds: string[] = [];
47
+ const newThreadIdMap = {} as Record<string, THREAD_MAPPING_ID>;
48
+ const newThreadData = {} as Record<THREAD_MAPPING_ID, RemoteThreadData>;
49
+
50
+ for (const thread of l.threads) {
51
+ if (thread.status === "regular") newThreadIds.push(thread.remoteId);
52
+ else newArchivedThreadIds.push(thread.remoteId);
53
+
54
+ const mappingId = createThreadMappingId(thread.remoteId);
55
+ newThreadIdMap[thread.remoteId] = mappingId;
56
+ newThreadData[mappingId] = {
57
+ id: thread.remoteId,
58
+ remoteId: thread.remoteId,
59
+ externalId: thread.externalId,
60
+ status: thread.status,
61
+ title: thread.title,
62
+ initializeTask: Promise.resolve({
63
+ remoteId: thread.remoteId,
64
+ externalId: thread.externalId,
65
+ }),
66
+ };
67
+ }
68
+
69
+ return {
70
+ ...state,
71
+ threadIds: newThreadIds,
72
+ archivedThreadIds: newArchivedThreadIds,
73
+ threadIdMap: { ...state.threadIdMap, ...newThreadIdMap },
74
+ threadData: { ...state.threadData, ...newThreadData },
75
+ };
76
+ };
77
+
78
+ /** Creates a deferred promise for controlling resolution order. */
79
+ function deferred<T>() {
80
+ let resolve!: (v: T) => void;
81
+ const promise = new Promise<T>((r) => {
82
+ resolve = r;
83
+ });
84
+ return { promise, resolve };
85
+ }
86
+
87
+ describe("list + delete race condition", () => {
88
+ it("stale list() does not re-add a thread deleted while list was in flight", async () => {
89
+ const state = new OptimisticState<RemoteThreadState>(EMPTY_STATE);
90
+
91
+ const listDeferred = deferred<ListResult>();
92
+ const deleteDeferred = deferred<void>();
93
+
94
+ // 1. list() starts
95
+ const listPromise = state.optimisticUpdate({
96
+ execute: () => listDeferred.promise,
97
+ loading: (s) => ({ ...s, isLoading: true }),
98
+ then: applyListResult,
99
+ });
100
+
101
+ // 2. delete starts (while list is in flight)
102
+ const deletePromise = state.optimisticUpdate({
103
+ execute: () => deleteDeferred.promise,
104
+ optimistic: (s) => updateStatusReducer(s, "thread-A", "deleted"),
105
+ });
106
+
107
+ // 3. DELETE resolves first
108
+ deleteDeferred.resolve();
109
+ await deletePromise;
110
+
111
+ // 4. list resolves with stale data that includes deleted thread
112
+ listDeferred.resolve({
113
+ threads: [{ remoteId: "thread-A", status: "regular", title: "A" }],
114
+ });
115
+ await listPromise;
116
+
117
+ // Thread A must NOT reappear
118
+ expect(state.value.threadIds).not.toContain("thread-A");
119
+ expect(
120
+ state.value.threadData[createThreadMappingId("thread-A")],
121
+ ).toBeUndefined();
122
+ });
123
+
124
+ it("stale list() does not revert archive back to regular", async () => {
125
+ // Start with thread A already in state
126
+ const mappingId = createThreadMappingId("thread-A");
127
+ const initial: RemoteThreadState = {
128
+ ...EMPTY_STATE,
129
+ threadIds: ["thread-A"],
130
+ threadIdMap: { "thread-A": mappingId },
131
+ threadData: {
132
+ [mappingId]: {
133
+ id: "thread-A",
134
+ remoteId: "thread-A",
135
+ externalId: undefined,
136
+ status: "regular",
137
+ title: "A",
138
+ initializeTask: Promise.resolve({
139
+ remoteId: "thread-A",
140
+ externalId: undefined,
141
+ }),
142
+ },
143
+ },
144
+ };
145
+
146
+ const state = new OptimisticState<RemoteThreadState>(initial);
147
+
148
+ const listDeferred = deferred<ListResult>();
149
+ const archiveDeferred = deferred<void>();
150
+
151
+ // 1. list() starts
152
+ const listPromise = state.optimisticUpdate({
153
+ execute: () => listDeferred.promise,
154
+ loading: (s) => ({ ...s, isLoading: true }),
155
+ then: applyListResult,
156
+ });
157
+
158
+ // 2. archive starts
159
+ const archivePromise = state.optimisticUpdate({
160
+ execute: () => archiveDeferred.promise,
161
+ optimistic: (s) => updateStatusReducer(s, "thread-A", "archived"),
162
+ });
163
+
164
+ // 3. archive resolves first
165
+ archiveDeferred.resolve();
166
+ await archivePromise;
167
+
168
+ // 4. list resolves with stale data (thread-A as regular)
169
+ listDeferred.resolve({
170
+ threads: [{ remoteId: "thread-A", status: "regular", title: "A" }],
171
+ });
172
+ await listPromise;
173
+
174
+ // Thread A should remain archived, NOT be reverted to regular
175
+ expect(state.value.threadIds).not.toContain("thread-A");
176
+ expect(state.value.archivedThreadIds).toContain("thread-A");
177
+ expect(
178
+ state.value.threadData[createThreadMappingId("thread-A")]?.status,
179
+ ).toBe("archived");
180
+ });
181
+
182
+ it("threads NOT modified during list() are loaded normally", async () => {
183
+ const state = new OptimisticState<RemoteThreadState>(EMPTY_STATE);
184
+
185
+ const listDeferred = deferred<ListResult>();
186
+ const deleteDeferred = deferred<void>();
187
+
188
+ // 1. list() starts
189
+ const listPromise = state.optimisticUpdate({
190
+ execute: () => listDeferred.promise,
191
+ loading: (s) => ({ ...s, isLoading: true }),
192
+ then: applyListResult,
193
+ });
194
+
195
+ // 2. delete thread A (while list is in flight)
196
+ const deletePromise = state.optimisticUpdate({
197
+ execute: () => deleteDeferred.promise,
198
+ optimistic: (s) => updateStatusReducer(s, "thread-A", "deleted"),
199
+ });
200
+
201
+ deleteDeferred.resolve();
202
+ await deletePromise;
203
+
204
+ // 3. list resolves with [A, B] — A should be filtered, B should load
205
+ listDeferred.resolve({
206
+ threads: [
207
+ { remoteId: "thread-A", status: "regular", title: "A" },
208
+ { remoteId: "thread-B", status: "regular", title: "B" },
209
+ ],
210
+ });
211
+ await listPromise;
212
+
213
+ expect(state.value.threadIds).toEqual(["thread-B"]);
214
+ expect(state.value.threadIds).not.toContain("thread-A");
215
+ expect(
216
+ state.value.threadData[createThreadMappingId("thread-B")],
217
+ ).toBeDefined();
218
+ });
219
+
220
+ it("list resolves before delete — no race, both work correctly", async () => {
221
+ const state = new OptimisticState<RemoteThreadState>(EMPTY_STATE);
222
+
223
+ const listDeferred = deferred<ListResult>();
224
+ const deleteDeferred = deferred<void>();
225
+
226
+ const listPromise = state.optimisticUpdate({
227
+ execute: () => listDeferred.promise,
228
+ loading: (s) => ({ ...s, isLoading: true }),
229
+ then: applyListResult,
230
+ });
231
+
232
+ const deletePromise = state.optimisticUpdate({
233
+ execute: () => deleteDeferred.promise,
234
+ optimistic: (s) => updateStatusReducer(s, "thread-A", "deleted"),
235
+ });
236
+
237
+ // list resolves FIRST this time
238
+ listDeferred.resolve({
239
+ threads: [{ remoteId: "thread-A", status: "regular", title: "A" }],
240
+ });
241
+ await listPromise;
242
+
243
+ // thread A is in base state now, but delete's optimistic still hides it
244
+ expect(state.value.threadIds).not.toContain("thread-A");
245
+
246
+ // delete resolves
247
+ deleteDeferred.resolve();
248
+ await deletePromise;
249
+
250
+ // thread A is gone from both base and cached
251
+ expect(state.value.threadIds).not.toContain("thread-A");
252
+ expect(
253
+ state.value.threadData[createThreadMappingId("thread-A")],
254
+ ).toBeUndefined();
255
+ });
256
+ });