@assistant-ui/core 0.2.21 → 0.2.22

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 (209) hide show
  1. package/dist/adapters/attachment.d.ts.map +1 -1
  2. package/dist/adapters/speech.d.ts.map +1 -1
  3. package/dist/adapters/speech.js +9 -2
  4. package/dist/adapters/speech.js.map +1 -1
  5. package/dist/adapters/voice.d.ts.map +1 -1
  6. package/dist/adapters/voice.js +13 -5
  7. package/dist/adapters/voice.js.map +1 -1
  8. package/dist/internal.d.ts +3 -1
  9. package/dist/internal.js +3 -1
  10. package/dist/model-context/frame/host.d.ts +1 -0
  11. package/dist/model-context/frame/host.d.ts.map +1 -1
  12. package/dist/model-context/frame/host.js +5 -0
  13. package/dist/model-context/frame/host.js.map +1 -1
  14. package/dist/model-context/frame/provider.d.ts.map +1 -1
  15. package/dist/model-context/frame/provider.js +1 -0
  16. package/dist/model-context/frame/provider.js.map +1 -1
  17. package/dist/model-context/interactable-composer-metadata.js.map +1 -1
  18. package/dist/react/AssistantProvider.js.map +1 -1
  19. package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
  20. package/dist/react/adapters/LocalStorageThreadListAdapter.js +77 -50
  21. package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
  22. package/dist/react/client/Interactables.js.map +1 -1
  23. package/dist/react/client/Tools.d.ts.map +1 -1
  24. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  25. package/dist/react/model-context/useInteractable.js.map +1 -1
  26. package/dist/react/primitive-hooks/useActionBarCopy.js +13 -13
  27. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  28. package/dist/react/primitive-hooks/useComposerAddAttachment.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
  30. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.d.ts.map +1 -1
  31. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
  32. package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
  33. package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
  34. package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
  35. package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
  36. package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
  37. package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
  38. package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
  39. package/dist/react/primitives/message/MessageParts.d.ts +2 -1
  40. package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
  41. package/dist/react/primitives/message/MessageParts.js +46 -36
  42. package/dist/react/primitives/message/MessageParts.js.map +1 -1
  43. package/dist/react/primitives/part/PartMessages.js.map +1 -1
  44. package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
  45. package/dist/react/primitives/thread/ThreadSuggestions.js.map +1 -1
  46. package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
  47. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.d.ts.map +1 -1
  48. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +18 -30
  49. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  50. package/dist/react/providers/PartByIndexProvider.d.ts.map +1 -1
  51. package/dist/react/providers/PartByIndexProvider.js +18 -30
  52. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  53. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  54. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -0
  55. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  56. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +9 -2
  57. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  58. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +49 -8
  59. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  60. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
  61. package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
  62. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
  63. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +2 -1
  64. package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
  65. package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
  66. package/dist/react/runtimes/external-message-converter.js +1 -1
  67. package/dist/react/runtimes/external-message-converter.js.map +1 -1
  68. package/dist/react/runtimes/useLocalRuntime.d.ts +1 -0
  69. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  70. package/dist/react/runtimes/useLocalRuntime.js +9 -1
  71. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  72. package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
  73. package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
  74. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  75. package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
  76. package/dist/runtime/api/message-runtime.d.ts.map +1 -1
  77. package/dist/runtime/api/message-runtime.js +4 -4
  78. package/dist/runtime/api/message-runtime.js.map +1 -1
  79. package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
  80. package/dist/runtime/api/thread-list-item-runtime.js +2 -1
  81. package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
  82. package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
  83. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  84. package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -1
  85. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  86. package/dist/runtime/base/base-composer-runtime-core.js +41 -14
  87. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  88. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  89. package/dist/runtime/base/base-thread-runtime-core.js +2 -1
  90. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  91. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  92. package/dist/runtime/base/default-edit-composer-runtime-core.js +4 -2
  93. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  94. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  95. package/dist/runtime/base/default-thread-composer-runtime-core.js +2 -2
  96. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  97. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  98. package/dist/runtime/utils/thread-message-like.js +4 -1
  99. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  100. package/dist/runtimes/external-store/external-store-runtime-core.d.ts.map +1 -1
  101. package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
  102. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  103. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  104. package/dist/runtimes/local/local-runtime-core.d.ts.map +1 -1
  105. package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
  106. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  107. package/dist/runtimes/local/local-thread-runtime-core.js +5 -2
  108. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  109. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  110. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +4 -4
  111. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  112. package/dist/runtimes/remote-thread-list/optimistic-state.js +22 -9
  113. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  114. package/dist/runtimes/remote-thread-list/types.d.ts +1 -1
  115. package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
  116. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +1 -2
  117. package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
  118. package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
  119. package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
  120. package/dist/store/runtime-clients/attachment-runtime-client.d.ts.map +1 -1
  121. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  122. package/dist/store/runtime-clients/message-part-runtime-client.d.ts.map +1 -1
  123. package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
  124. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  125. package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
  126. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  127. package/dist/subscribable/subscribable.d.ts +1 -1
  128. package/dist/subscribable/subscribable.d.ts.map +1 -1
  129. package/dist/subscribable/subscribable.js +11 -3
  130. package/dist/subscribable/subscribable.js.map +1 -1
  131. package/dist/tests/remote-thread-list-test-helpers.d.ts +2 -1
  132. package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
  133. package/dist/tests/remote-thread-list-test-helpers.js +5 -2
  134. package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
  135. package/dist/types/attachment.d.ts +2 -1
  136. package/dist/types/attachment.d.ts.map +1 -1
  137. package/dist/types/attachment.js +6 -0
  138. package/dist/types/attachment.js.map +1 -0
  139. package/dist/types/message.d.ts +1 -0
  140. package/dist/types/message.d.ts.map +1 -1
  141. package/dist/types/message.js.map +1 -1
  142. package/dist/utils/data-url.d.ts +9 -0
  143. package/dist/utils/data-url.d.ts.map +1 -0
  144. package/dist/utils/data-url.js +14 -0
  145. package/dist/utils/data-url.js.map +1 -0
  146. package/dist/utils/notify-event-listeners.d.ts +6 -0
  147. package/dist/utils/notify-event-listeners.d.ts.map +1 -0
  148. package/dist/utils/notify-event-listeners.js +16 -0
  149. package/dist/utils/notify-event-listeners.js.map +1 -0
  150. package/package.json +10 -9
  151. package/src/adapters/speech.test.ts +88 -0
  152. package/src/adapters/speech.ts +17 -2
  153. package/src/adapters/voice.test.ts +130 -0
  154. package/src/adapters/voice.ts +26 -6
  155. package/src/internal.ts +5 -0
  156. package/src/model-context/frame/host.test.ts +100 -0
  157. package/src/model-context/frame/host.ts +10 -0
  158. package/src/model-context/frame/provider.test.ts +83 -0
  159. package/src/model-context/frame/provider.ts +1 -0
  160. package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +82 -0
  161. package/src/react/adapters/LocalStorageThreadListAdapter.tsx +94 -54
  162. package/src/react/primitive-hooks/useActionBarCopy.test.ts +79 -0
  163. package/src/react/primitive-hooks/useActionBarCopy.ts +4 -3
  164. package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -3
  165. package/src/react/primitives/message/MessageParts.tsx +48 -35
  166. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +19 -2
  167. package/src/react/providers/PartByIndexProvider.tsx +16 -2
  168. package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +18 -0
  169. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +1 -0
  170. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +48 -7
  171. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +69 -0
  172. package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +1 -1
  173. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +16 -0
  174. package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +2 -1
  175. package/src/react/runtimes/external-message-converter.test.tsx +21 -0
  176. package/src/react/runtimes/external-message-converter.ts +1 -1
  177. package/src/react/runtimes/useLocalRuntime.test.tsx +56 -0
  178. package/src/react/runtimes/useLocalRuntime.ts +13 -1
  179. package/src/runtime/api/message-runtime.test.ts +81 -0
  180. package/src/runtime/api/message-runtime.ts +4 -6
  181. package/src/runtime/api/thread-list-item-runtime.ts +6 -1
  182. package/src/runtime/base/base-composer-runtime-core.ts +77 -31
  183. package/src/runtime/base/base-thread-runtime-core.ts +2 -1
  184. package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -2
  185. package/src/runtime/base/default-thread-composer-runtime-core.ts +2 -2
  186. package/src/runtime/utils/thread-message-like.ts +1 -0
  187. package/src/runtimes/local/local-thread-runtime-core.test.ts +150 -1
  188. package/src/runtimes/local/local-thread-runtime-core.ts +4 -2
  189. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +63 -0
  190. package/src/runtimes/remote-thread-list/optimistic-state.ts +49 -17
  191. package/src/runtimes/remote-thread-list/types.ts +1 -1
  192. package/src/runtimes/tool-invocations/EDGE_CASES.md +13 -19
  193. package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +127 -6
  194. package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +4 -2
  195. package/src/subscribable/subscribable.test.ts +58 -0
  196. package/src/subscribable/subscribable.ts +13 -3
  197. package/src/tests/OptimisticState-list-race.test.ts +33 -0
  198. package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +223 -0
  199. package/src/tests/base-composer-runtime-core-addAttachment.test.ts +44 -0
  200. package/src/tests/base-composer-runtime-core-send.test.ts +433 -5
  201. package/src/tests/default-edit-composer-runtime-core.test.ts +43 -5
  202. package/src/tests/event-subscription-listener-errors.test.ts +205 -0
  203. package/src/tests/remote-thread-list-test-helpers.ts +14 -7
  204. package/src/tests/thread-message-like.test.ts +57 -0
  205. package/src/types/attachment.ts +5 -0
  206. package/src/types/message.ts +1 -0
  207. package/src/utils/data-url.test.ts +50 -0
  208. package/src/utils/data-url.ts +9 -0
  209. package/src/utils/notify-event-listeners.ts +30 -0
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { LocalRuntimeCore } from "./local-runtime-core";
3
3
  import type {
4
4
  ChatModelAdapter,
@@ -10,6 +10,10 @@ import type { LocalRuntimeOptionsBase } from "./local-runtime-options";
10
10
 
11
11
  const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
12
12
 
13
+ afterEach(() => {
14
+ vi.restoreAllMocks();
15
+ });
16
+
13
17
  const createThread = (
14
18
  adapter: ChatModelAdapter,
15
19
  options?: {
@@ -74,6 +78,64 @@ const createApprovalThread = (firstResult: ChatModelRunResult) => {
74
78
  return { thread, runs };
75
79
  };
76
80
 
81
+ describe("LocalThreadRuntimeCore events", () => {
82
+ it("isolates runEnd listener errors", async () => {
83
+ const listenerError = new Error("telemetry failed");
84
+ const consoleError = vi
85
+ .spyOn(console, "error")
86
+ .mockImplementation(() => {});
87
+ const laterListener = vi.fn();
88
+ const thread = createThread({
89
+ async run() {
90
+ return { content: [{ type: "text", text: "done" }] };
91
+ },
92
+ });
93
+
94
+ thread.unstable_on("runEnd", () => {
95
+ throw listenerError;
96
+ });
97
+ thread.unstable_on("runEnd", laterListener);
98
+
99
+ await expect(thread.append(userMessage("hello"))).resolves.toBeUndefined();
100
+
101
+ expect(laterListener).toHaveBeenCalledOnce();
102
+ expect(thread.messages.at(-1)?.status?.type).toBe("complete");
103
+ expect(consoleError).toHaveBeenCalledWith(
104
+ '[assistant-ui] Thread runtime "runEnd" listener threw an error',
105
+ listenerError,
106
+ );
107
+ });
108
+
109
+ it("isolates async runEnd listener rejections", async () => {
110
+ const listenerError = new Error("async telemetry failed");
111
+ const consoleError = vi
112
+ .spyOn(console, "error")
113
+ .mockImplementation(() => {});
114
+ const laterListener = vi.fn();
115
+ const thread = createThread({
116
+ async run() {
117
+ return { content: [{ type: "text", text: "done" }] };
118
+ },
119
+ });
120
+
121
+ thread.unstable_on("runEnd", async () => {
122
+ throw listenerError;
123
+ });
124
+ thread.unstable_on("runEnd", laterListener);
125
+
126
+ await expect(thread.append(userMessage("hello"))).resolves.toBeUndefined();
127
+
128
+ expect(laterListener).toHaveBeenCalledOnce();
129
+ expect(thread.messages.at(-1)?.status?.type).toBe("complete");
130
+ await vi.waitFor(() => {
131
+ expect(consoleError).toHaveBeenCalledWith(
132
+ '[assistant-ui] Thread runtime "runEnd" listener threw an error',
133
+ listenerError,
134
+ );
135
+ });
136
+ });
137
+ });
138
+
77
139
  describe("LocalThreadRuntimeCore human-in-the-loop tools", () => {
78
140
  it("pauses on requires-action while a listed tool call has no result", async () => {
79
141
  const { thread, runs } = createApprovalThread(toolCallResult("send_email"));
@@ -158,6 +220,25 @@ describe("LocalThreadRuntimeCore human-in-the-loop tools", () => {
158
220
  });
159
221
  });
160
222
 
223
+ describe("LocalThreadRuntimeCore state", () => {
224
+ it.each([
225
+ ["false", false],
226
+ ["zero", 0],
227
+ ["an empty string", ""],
228
+ ])("preserves %s model state", async (_label, state) => {
229
+ const thread = createThread({
230
+ async run() {
231
+ return { metadata: { unstable_state: state } };
232
+ },
233
+ });
234
+
235
+ await thread.append(userMessage("update state"));
236
+ await flush();
237
+
238
+ expect(thread.messages.at(-1)?.metadata.unstable_state).toBe(state);
239
+ });
240
+ });
241
+
161
242
  describe("LocalThreadRuntimeCore tool approvals", () => {
162
243
  it("pauses the run while an approval is pending, even for unlisted tools", async () => {
163
244
  const { thread, runs } = createApprovalThread(
@@ -408,6 +489,74 @@ describe("LocalThreadRuntimeCore tool approvals", () => {
408
489
  });
409
490
  });
410
491
 
492
+ describe("LocalThreadRuntimeCore cancellation", () => {
493
+ it("marks the message cancelled when a streaming adapter returns after abort", async () => {
494
+ let released!: () => void;
495
+ const streaming = new Promise<void>((resolve) => {
496
+ released = resolve;
497
+ });
498
+
499
+ const thread = createThread({
500
+ async *run({ abortSignal }) {
501
+ yield { content: [{ type: "text", text: "partial" }] };
502
+ await streaming;
503
+ if (abortSignal.aborted) return;
504
+ yield { content: [{ type: "text", text: "partial answer" }] };
505
+ },
506
+ });
507
+
508
+ const appendPromise = thread.append(userMessage("hi"));
509
+ await flush();
510
+
511
+ thread.cancelRun();
512
+ released();
513
+ await appendPromise;
514
+
515
+ expect(thread.messages.at(-1)?.status).toEqual({
516
+ type: "incomplete",
517
+ reason: "cancelled",
518
+ });
519
+ });
520
+
521
+ it("marks the message cancelled when a non-streaming adapter resolves after abort", async () => {
522
+ let released!: () => void;
523
+ const pending = new Promise<void>((resolve) => {
524
+ released = resolve;
525
+ });
526
+
527
+ const thread = createThread({
528
+ async run() {
529
+ await pending;
530
+ return { content: [{ type: "text", text: "hello" }] };
531
+ },
532
+ });
533
+
534
+ const appendPromise = thread.append(userMessage("hi"));
535
+ await flush();
536
+
537
+ thread.cancelRun();
538
+ released();
539
+ await appendPromise;
540
+
541
+ expect(thread.messages.at(-1)?.status).toEqual({
542
+ type: "incomplete",
543
+ reason: "cancelled",
544
+ });
545
+ });
546
+
547
+ it("keeps a completed run complete", async () => {
548
+ const thread = createThread({
549
+ async *run() {
550
+ yield { content: [{ type: "text", text: "hello" }] };
551
+ },
552
+ });
553
+
554
+ await thread.append(userMessage("hi"));
555
+
556
+ expect(thread.messages.at(-1)?.status?.type).toBe("complete");
557
+ });
558
+ });
559
+
411
560
  describe("LocalThreadRuntimeCore suggestions", () => {
412
561
  it("cancelRun aborts pending suggestion generation", async () => {
413
562
  const generate = vi.fn().mockImplementation(
@@ -442,7 +442,7 @@ export class LocalThreadRuntimeCore
442
442
  ? {
443
443
  metadata: {
444
444
  ...message.metadata,
445
- ...(m.metadata.unstable_state
445
+ ...(m.metadata.unstable_state !== undefined
446
446
  ? { unstable_state: m.metadata.unstable_state }
447
447
  : undefined),
448
448
  ...(annotations
@@ -537,7 +537,9 @@ export class LocalThreadRuntimeCore
537
537
 
538
538
  if (message.status.type === "running") {
539
539
  updateMessage({
540
- status: { type: "complete", reason: "unknown" },
540
+ status: abortSignal.aborted
541
+ ? { type: "incomplete", reason: "cancelled" }
542
+ : { type: "complete", reason: "unknown" },
541
543
  });
542
544
  }
543
545
  } catch (e) {
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { OptimisticState } from "./optimistic-state";
3
+
4
+ const deferred = () => {
5
+ let resolve!: () => void;
6
+ const promise = new Promise<void>((resolvePromise) => {
7
+ resolve = resolvePromise;
8
+ });
9
+
10
+ return { promise, resolve };
11
+ };
12
+
13
+ describe("OptimisticState", () => {
14
+ it("preserves invocation order when optimistic updates resolve in order", async () => {
15
+ const state = new OptimisticState({ title: "Untitled" });
16
+ const firstRequest = deferred();
17
+ const secondRequest = deferred();
18
+
19
+ const firstUpdate = state.optimisticUpdate({
20
+ execute: () => firstRequest.promise,
21
+ optimistic: (value) => ({ ...value, title: "Project Alpha" }),
22
+ });
23
+ const secondUpdate = state.optimisticUpdate({
24
+ execute: () => secondRequest.promise,
25
+ optimistic: (value) => ({ ...value, title: "Project Beta" }),
26
+ });
27
+
28
+ expect(state.value.title).toBe("Project Beta");
29
+
30
+ firstRequest.resolve();
31
+ await firstUpdate;
32
+ expect(state.value.title).toBe("Project Beta");
33
+
34
+ secondRequest.resolve();
35
+ await secondUpdate;
36
+
37
+ expect(state.value.title).toBe("Project Beta");
38
+ });
39
+
40
+ it("preserves invocation order when optimistic updates resolve out of order", async () => {
41
+ const state = new OptimisticState({ title: "Untitled" });
42
+ const firstRequest = deferred();
43
+ const secondRequest = deferred();
44
+
45
+ const firstUpdate = state.optimisticUpdate({
46
+ execute: () => firstRequest.promise,
47
+ optimistic: (value) => ({ ...value, title: "Project Alpha" }),
48
+ });
49
+ const secondUpdate = state.optimisticUpdate({
50
+ execute: () => secondRequest.promise,
51
+ optimistic: (value) => ({ ...value, title: "Project Beta" }),
52
+ });
53
+
54
+ secondRequest.resolve();
55
+ await secondUpdate;
56
+ expect(state.value.title).toBe("Project Beta");
57
+
58
+ firstRequest.resolve();
59
+ await firstUpdate;
60
+
61
+ expect(state.value.title).toBe("Project Beta");
62
+ });
63
+ });
@@ -14,9 +14,15 @@ type Transform<TState, TResult> = {
14
14
  };
15
15
 
16
16
  type PendingTransform<TState, TResult> = Transform<TState, TResult> & {
17
+ order: number;
17
18
  task: Promise<TResult>;
18
19
  };
19
20
 
21
+ type CompletedOptimistic<TState> = {
22
+ order: number;
23
+ optimistic: (state: TState) => TState;
24
+ };
25
+
20
26
  const pipeTransforms = <TState, TExtra>(
21
27
  initialState: TState,
22
28
  extraParam: TExtra,
@@ -32,14 +38,16 @@ export class OptimisticState<TState> extends BaseSubscribable {
32
38
  [];
33
39
 
34
40
  /**
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.
41
+ * Completed optimistic callbacks remain active while any transform is
42
+ * pending, so later state replacements cannot erase them. Invocation order
43
+ * determines which overlapping optimistic update wins.
39
44
  *
40
45
  * Correctness requirement: `optimistic` callbacks must be idempotent.
41
46
  */
42
- private readonly _completedOptimistics: Array<(state: TState) => TState> = [];
47
+ private readonly _completedOptimistics: Array<CompletedOptimistic<TState>> =
48
+ [];
49
+
50
+ private _nextTransformOrder = 0;
43
51
 
44
52
  private _baseValue: TState;
45
53
  private _cachedValue: TState;
@@ -51,12 +59,25 @@ export class OptimisticState<TState> extends BaseSubscribable {
51
59
  }
52
60
 
53
61
  private _updateState(): void {
54
- this._cachedValue = this._pendingTransforms.reduce((state, transform) => {
55
- return pipeTransforms(state, transform.task, [
56
- transform.loading,
57
- transform.optimistic,
58
- ]);
59
- }, this._baseValue);
62
+ const activeTransforms = [
63
+ ...this._pendingTransforms.map((transform) => ({
64
+ order: transform.order,
65
+ apply: (state: TState) =>
66
+ pipeTransforms(state, transform.task, [
67
+ transform.loading,
68
+ transform.optimistic,
69
+ ]),
70
+ })),
71
+ ...this._completedOptimistics.map(({ order, optimistic }) => ({
72
+ order,
73
+ apply: optimistic,
74
+ })),
75
+ ].sort((a, b) => a.order - b.order);
76
+
77
+ this._cachedValue = activeTransforms.reduce(
78
+ (state, transform) => transform.apply(state),
79
+ this._baseValue,
80
+ );
60
81
 
61
82
  this._notifySubscribers();
62
83
  }
@@ -77,8 +98,13 @@ export class OptimisticState<TState> extends BaseSubscribable {
77
98
  public async optimisticUpdate<TResult>(
78
99
  transform: Transform<TState, TResult>,
79
100
  ): Promise<TResult> {
101
+ const order = this._nextTransformOrder++;
80
102
  const task = transform.execute();
81
- const pendingTransform = { ...transform, task };
103
+ const pendingTransform = {
104
+ ...transform,
105
+ order,
106
+ task,
107
+ };
82
108
  try {
83
109
  this._pendingTransforms.push(pendingTransform);
84
110
  this._updateState();
@@ -89,14 +115,20 @@ export class OptimisticState<TState> extends BaseSubscribable {
89
115
  transform.then,
90
116
  ]);
91
117
 
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);
118
+ // `then` can replace state with a stale snapshot, so replay every
119
+ // completed effect; otherwise replay only newer overlapping effects.
120
+ for (const completed of this._completedOptimistics) {
121
+ if (transform.then || completed.order > pendingTransform.order) {
122
+ this._baseValue = completed.optimistic(this._baseValue);
123
+ }
96
124
  }
97
125
 
98
126
  if (transform.optimistic) {
99
- this._completedOptimistics.push(transform.optimistic);
127
+ this._completedOptimistics.push({
128
+ order: pendingTransform.order,
129
+ optimistic: transform.optimistic,
130
+ });
131
+ this._completedOptimistics.sort((a, b) => a.order - b.order);
100
132
  }
101
133
 
102
134
  return result;
@@ -5,7 +5,7 @@ import type { AssistantStream } from "assistant-stream";
5
5
 
6
6
  export type RemoteThreadInitializeResponse = {
7
7
  remoteId: string;
8
- externalId: string | undefined;
8
+ externalId?: string | undefined;
9
9
  };
10
10
 
11
11
  export type RemoteThreadMetadata = {
@@ -41,6 +41,15 @@ Subsequent snapshots that *are* prefixes of the new (regressed) snapshot
41
41
  also won't be appended, because `entry.argsText` still points at the
42
42
  pre-regression value used for delta calculation.
43
43
 
44
+ The args stream closes only when the controller's *streamed* content
45
+ (`entry.argsText`) is complete, not when a later snapshot is. A divergent
46
+ snapshot can be complete while the controller still holds an incomplete
47
+ stale prefix; closing on the snapshot would parse that stale prefix and
48
+ auto-submit a bogus parse-error result (resuming the host graph and
49
+ abandoning a pending interrupt). Gating the close on the streamed content
50
+ leaves the stream open until the prefix itself completes, so no stale
51
+ parse runs and no error result is fabricated from divergent args.
52
+
44
53
  ### A.3. Args complete then equivalent-JSON key reorder
45
54
  Both old and new `argsText` parse to equivalent JSON values (e.g. keys
46
55
  reordered by the backend). The tracker updates its tracked `argsText`
@@ -53,9 +62,10 @@ existing `streamCall` keeps its original args view.
53
62
 
54
63
  ### A.5. First resolution (`result` becomes defined)
55
64
  The tracker calls `setResponse` on the active controller and closes it.
56
- `reader.response.get()` resolves. If the tool also had a frontend
57
- `execute`, the executor is short-circuited via `_skipExecuteStreamIds`.
58
- Single fire.
65
+ The backend result is emitted before the args stream closes, so a stale
66
+ args parse failure cannot replace it. `reader.response.get()` resolves.
67
+ If the tool also had a frontend `execute`, the executor is short-circuited
68
+ via `_skipExecuteStreamIds`. Single fire.
59
69
 
60
70
  ### A.6. Previously-resolved tool's `result` is replaced
61
71
  Silently ignored — `entry.hasResult` short-circuits both the
@@ -167,22 +177,6 @@ keep the tracker dead with a visible error to avoid restart loops.
167
177
 
168
178
  ## Known limitations
169
179
 
170
- ### Result delivery after args regression (A.2 + A.5 in the same snapshot)
171
- When a snapshot has both a regressed `argsText` *and* a backend result
172
- on the same tool call, `activeController.setResponse(result)` closes
173
- `argsText` before enqueueing the result chunk. The args-text-finish
174
- chunk reaches `ToolExecutionStream` first, attempts to parse the
175
- (stale) accumulated argsText, fails, and emits a parse-error result
176
- that beats the backend result to the reader's response promise.
177
-
178
- The tracker's `entry.hasResult` short-circuit *does* suppress both
179
- result chunks at the `onResult` callback level (no double-fire), but
180
- the reader's `response.get()` already resolved with the parse error.
181
-
182
- Fixable upstream in `ToolCallStreamControllerImpl.setResponse` by
183
- enqueueing the result chunk before closing argsText. Tracked separately;
184
- out of scope for the tracker layer.
185
-
186
180
  ### Host callback throws
187
181
  `onResult` and `onStatusesChange` are invoked through wrappers that
188
182
  catch and log. The tracker continues to function; the host's bad
@@ -152,6 +152,131 @@ describe("ToolInvocationTracker", () => {
152
152
  }
153
153
  });
154
154
 
155
+ it("does not auto-submit a parse-error result when divergent argsText closes without a backend result (#5130)", async () => {
156
+ // Mirrors the #5098 production shape: a human-in-the-loop tool whose
157
+ // argsText diverges mid-stream and never re-converges, with no backend
158
+ // result at close time. The args stream must not close on the divergent
159
+ // snapshot (which holds a stale prefix the execution path would parse),
160
+ // so no bogus parse-error result is auto-submitted to resume the host
161
+ // graph and abandon the pending interrupt.
162
+ const execute = vi.fn(async () => ({ forecast: "ok" }));
163
+ const streamCall = vi.fn((_reader, { human }) => {
164
+ // Request human input immediately — sets up the pending interrupt.
165
+ void human({ request: "approve" });
166
+ });
167
+ const getTools = () => ({
168
+ weatherSearch: {
169
+ parameters: { type: "object", properties: {} },
170
+ execute,
171
+ streamCall,
172
+ } satisfies Tool,
173
+ });
174
+ const onResult = vi.fn();
175
+ let statuses: Record<string, ToolExecutionStatus> = {};
176
+ const onStatusesChange = (s: ReadonlyMap<string, ToolExecutionStatus>) => {
177
+ statuses = Object.fromEntries(s);
178
+ };
179
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
180
+
181
+ try {
182
+ const tracker = new ToolInvocationTracker(getTools, {
183
+ onResult,
184
+ onStatusesChange,
185
+ });
186
+ tracker.setState(createState([]));
187
+
188
+ tracker.setState(
189
+ createState([
190
+ createAssistantMessage('{"query":"London","longitude":0', {
191
+ query: "London",
192
+ longitude: 0,
193
+ }),
194
+ ]),
195
+ );
196
+
197
+ // The pending interrupt is set up via streamCall → human().
198
+ await waitFor(() => {
199
+ expect(statuses["tool-1"]?.type).toBe("interrupt");
200
+ });
201
+
202
+ // Divergent regression (not a prefix of the streamed text).
203
+ tracker.setState(
204
+ createState([
205
+ createAssistantMessage('{"query":"London","longitude":-0.125', {
206
+ query: "London",
207
+ longitude: -0.125,
208
+ }),
209
+ ]),
210
+ );
211
+
212
+ // Complete valid JSON, divergent from the streamed prefix, no backend
213
+ // result. Previously this closed the args stream on the snapshot's
214
+ // completeness, parsed the stale prefix, and auto-submitted an error.
215
+ tracker.setState(
216
+ createState([
217
+ createAssistantMessage(
218
+ '{"query":"London","longitude":-0.125,"latitude":51.5072}',
219
+ { query: "London", longitude: -0.125, latitude: 51.5072 },
220
+ ),
221
+ ]),
222
+ );
223
+
224
+ for (let i = 0; i < 5; i++) {
225
+ await new Promise((r) => setTimeout(r, 0));
226
+ }
227
+
228
+ // No bogus parse-error result is auto-submitted, so the host graph is
229
+ // not resumed with a fake tool failure and the pending interrupt is
230
+ // preserved.
231
+ expect(onResult).not.toHaveBeenCalled();
232
+ // The frontend execute never ran: the stale prefix was never parsed.
233
+ expect(execute).not.toHaveBeenCalled();
234
+ } finally {
235
+ warnSpy.mockRestore();
236
+ }
237
+ });
238
+
239
+ it("does not auto-submit a parse-error result for a non-executable tool whose divergent argsText closes (#5130)", async () => {
240
+ // Same close-gating mismatch as the executable case, but for a tool with
241
+ // no frontend execute. Closing on the divergent complete snapshot would
242
+ // still parse the incomplete stale prefix and fabricate a parse-error
243
+ // result, so the close must gate on the controller's streamed content.
244
+ const getTools = () => ({
245
+ weatherSearch: {
246
+ parameters: { type: "object", properties: {} },
247
+ } satisfies Tool,
248
+ });
249
+ const onResult = vi.fn();
250
+ const onStatusesChange = () => {};
251
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
252
+
253
+ try {
254
+ const tracker = new ToolInvocationTracker(getTools, {
255
+ onResult,
256
+ onStatusesChange,
257
+ });
258
+ tracker.setState(createState([]));
259
+
260
+ // Incomplete prefix while the run is still streaming.
261
+ tracker.setState(
262
+ createState([createAssistantMessage('{"a":1', { a: 1 })], true),
263
+ );
264
+
265
+ // Run settles and the snapshot regresses to a complete divergent text.
266
+ tracker.setState(
267
+ createState([createAssistantMessage('{"a":2}', { a: 2 })], false),
268
+ );
269
+
270
+ for (let i = 0; i < 5; i++) {
271
+ await new Promise((r) => setTimeout(r, 0));
272
+ }
273
+
274
+ expect(onResult).not.toHaveBeenCalled();
275
+ } finally {
276
+ warnSpy.mockRestore();
277
+ }
278
+ });
279
+
155
280
  it("clears executing status under the logical toolCallId when reset() lands while execute is pending", async () => {
156
281
  // Tests the F.1 lifecycle: reset() aborts in-flight execute() invocations
157
282
  // and clears their executing status. The status key is the logical
@@ -948,12 +1073,8 @@ describe("ToolInvocationTracker", () => {
948
1073
  // result replacement) and verifying streamCall fires exactly once.
949
1074
  //
950
1075
  // The pathological mid-stream regression case (A.2) is covered by
951
- // the dedicated regression test above. Mixing A.2 with a backend
952
- // result in the same snapshot exposes a separate issue inside
953
- // assistant-stream's `ToolCallStreamController.setResponse` ordering
954
- // (parse-failure result reaches the reader before the backend
955
- // result); that's tracked separately and out of scope for the
956
- // tracker-level contract.
1076
+ // the assistant-stream ordering regression test in
1077
+ // packages/assistant-stream/src/core/modules/tool-call.test.ts.
957
1078
  const streamCall = vi.fn();
958
1079
  const execute = vi.fn(async () => ({ forecast: "ok" }));
959
1080
  const getTools = () => ({
@@ -687,14 +687,16 @@ export class ToolInvocationTracker {
687
687
  }
688
688
 
689
689
  if (!entry.argsComplete && entry.controller) {
690
+ // ToolExecutionStream parses the streamed prefix on close, so the close
691
+ // gates on the streamed content; a divergent snapshot (A.2) can be
692
+ // complete while the controller still holds an incomplete stale prefix.
690
693
  const shouldClose = this._shouldCloseArgsStream({
691
694
  toolName: content.toolName,
692
- argsText: content.argsText,
695
+ argsText: entry.argsText,
693
696
  hasResult,
694
697
  });
695
698
  if (shouldClose) {
696
699
  entry.controller.argsText.close();
697
- entry.argsText = content.argsText;
698
700
  entry.argsComplete = true;
699
701
  }
700
702
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { SubscribableWithState } from "./subscribable";
3
+ import { ShallowMemoizeSubject } from "./subscribable";
4
+
5
+ type TestState = {
6
+ status: string;
7
+ error?: string;
8
+ };
9
+
10
+ const createBinding = (initialState: TestState) => {
11
+ let state = initialState;
12
+ const subscribers = new Set<() => void>();
13
+
14
+ const binding: SubscribableWithState<TestState, null> = {
15
+ path: null,
16
+ getState: () => state,
17
+ subscribe: (callback) => {
18
+ subscribers.add(callback);
19
+ return () => subscribers.delete(callback);
20
+ },
21
+ };
22
+
23
+ return {
24
+ binding,
25
+ update(nextState: TestState) {
26
+ state = nextState;
27
+ for (const callback of subscribers) callback();
28
+ },
29
+ };
30
+ };
31
+
32
+ describe("ShallowMemoizeSubject", () => {
33
+ it("notifies subscribers when a state key is removed", () => {
34
+ const source = createBinding({
35
+ status: "running",
36
+ error: "Connection failed",
37
+ });
38
+ const subject = new ShallowMemoizeSubject(source.binding);
39
+ const subscriber = vi.fn();
40
+ subject.subscribe(subscriber);
41
+
42
+ source.update({ status: "running" });
43
+
44
+ expect(subscriber).toHaveBeenCalledOnce();
45
+ expect(subject.getState()).toEqual({ status: "running" });
46
+ });
47
+
48
+ it("does not notify subscribers for a shallow-equal state", () => {
49
+ const source = createBinding({ status: "running" });
50
+ const subject = new ShallowMemoizeSubject(source.binding);
51
+ const subscriber = vi.fn();
52
+ subject.subscribe(subscriber);
53
+
54
+ source.update({ status: "running" });
55
+
56
+ expect(subscriber).not.toHaveBeenCalled();
57
+ });
58
+ });