@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.
- package/dist/adapters/attachment.d.ts.map +1 -1
- package/dist/adapters/speech.d.ts.map +1 -1
- package/dist/adapters/speech.js +9 -2
- package/dist/adapters/speech.js.map +1 -1
- package/dist/adapters/voice.d.ts.map +1 -1
- package/dist/adapters/voice.js +13 -5
- package/dist/adapters/voice.js.map +1 -1
- package/dist/internal.d.ts +3 -1
- package/dist/internal.js +3 -1
- package/dist/model-context/frame/host.d.ts +1 -0
- package/dist/model-context/frame/host.d.ts.map +1 -1
- package/dist/model-context/frame/host.js +5 -0
- package/dist/model-context/frame/host.js.map +1 -1
- package/dist/model-context/frame/provider.d.ts.map +1 -1
- package/dist/model-context/frame/provider.js +1 -0
- package/dist/model-context/frame/provider.js.map +1 -1
- package/dist/model-context/interactable-composer-metadata.js.map +1 -1
- package/dist/react/AssistantProvider.js.map +1 -1
- package/dist/react/adapters/LocalStorageThreadListAdapter.d.ts.map +1 -1
- package/dist/react/adapters/LocalStorageThreadListAdapter.js +77 -50
- package/dist/react/adapters/LocalStorageThreadListAdapter.js.map +1 -1
- package/dist/react/client/Interactables.js.map +1 -1
- package/dist/react/client/Tools.d.ts.map +1 -1
- package/dist/react/client/interactable-model-context.d.ts.map +1 -1
- package/dist/react/model-context/useInteractable.js.map +1 -1
- package/dist/react/primitive-hooks/useActionBarCopy.js +13 -13
- package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
- package/dist/react/primitive-hooks/useComposerAddAttachment.d.ts.map +1 -1
- package/dist/react/primitive-hooks/useComposerSend.d.ts.map +1 -1
- package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.d.ts.map +1 -1
- package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js +1 -1
- package/dist/react/primitives/chainOfThought/ChainOfThoughtParts.js.map +1 -1
- package/dist/react/primitives/composer/ComposerAttachments.js.map +1 -1
- package/dist/react/primitives/composer/ComposerQueue.d.ts.map +1 -1
- package/dist/react/primitives/composer/ComposerQueue.js.map +1 -1
- package/dist/react/primitives/generativeUI/GenerativeUI.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageAttachments.js.map +1 -1
- package/dist/react/primitives/message/MessageGroupedParts.js.map +1 -1
- package/dist/react/primitives/message/MessageParts.d.ts +2 -1
- package/dist/react/primitives/message/MessageParts.d.ts.map +1 -1
- package/dist/react/primitives/message/MessageParts.js +46 -36
- package/dist/react/primitives/message/MessageParts.js.map +1 -1
- package/dist/react/primitives/part/PartMessages.js.map +1 -1
- package/dist/react/primitives/thread/ThreadMessages.js.map +1 -1
- package/dist/react/primitives/thread/ThreadSuggestions.js.map +1 -1
- package/dist/react/primitives/threadList/ThreadListItems.js.map +1 -1
- package/dist/react/providers/ChainOfThoughtPartByIndexProvider.d.ts.map +1 -1
- package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +18 -30
- package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
- package/dist/react/providers/PartByIndexProvider.d.ts.map +1 -1
- package/dist/react/providers/PartByIndexProvider.js +18 -30
- package/dist/react/providers/PartByIndexProvider.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +1 -0
- package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +9 -2
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +49 -8
- package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js +1 -1
- package/dist/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.js.map +1 -1
- package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.d.ts.map +1 -1
- package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js +2 -1
- package/dist/react/runtimes/cloud/CloudFileAttachmentAdapter.js.map +1 -1
- package/dist/react/runtimes/createMessageConverter.d.ts.map +1 -1
- package/dist/react/runtimes/external-message-converter.js +1 -1
- package/dist/react/runtimes/external-message-converter.js.map +1 -1
- package/dist/react/runtimes/useLocalRuntime.d.ts +1 -0
- package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
- package/dist/react/runtimes/useLocalRuntime.js +9 -1
- package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
- package/dist/runtime/api/assistant-runtime.d.ts.map +1 -1
- package/dist/runtime/api/attachment-runtime.d.ts.map +1 -1
- package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-part-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-runtime.d.ts.map +1 -1
- package/dist/runtime/api/message-runtime.js +4 -4
- package/dist/runtime/api/message-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-list-item-runtime.js +2 -1
- package/dist/runtime/api/thread-list-item-runtime.js.map +1 -1
- package/dist/runtime/api/thread-list-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +4 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +41 -14
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js +2 -1
- package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-edit-composer-runtime-core.js +4 -2
- package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/default-thread-composer-runtime-core.js +2 -2
- package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
- package/dist/runtime/utils/message-repository.d.ts.map +1 -1
- package/dist/runtime/utils/thread-message-like.js +4 -1
- package/dist/runtime/utils/thread-message-like.js.map +1 -1
- package/dist/runtimes/external-store/external-store-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/local/local-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-list-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtimes/local/local-thread-runtime-core.js +5 -2
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +4 -4
- package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
- package/dist/runtimes/remote-thread-list/optimistic-state.js +22 -9
- package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
- package/dist/runtimes/remote-thread-list/types.d.ts +1 -1
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.d.ts.map +1 -1
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.js +1 -2
- package/dist/runtimes/tool-invocations/ToolInvocationTracker.js.map +1 -1
- package/dist/store/clients/chain-of-thought-client.d.ts.map +1 -1
- package/dist/store/clients/no-op-composer-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/attachment-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/message-part-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/message-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
- package/dist/subscribable/subscribable.d.ts +1 -1
- package/dist/subscribable/subscribable.d.ts.map +1 -1
- package/dist/subscribable/subscribable.js +11 -3
- package/dist/subscribable/subscribable.js.map +1 -1
- package/dist/tests/remote-thread-list-test-helpers.d.ts +2 -1
- package/dist/tests/remote-thread-list-test-helpers.d.ts.map +1 -1
- package/dist/tests/remote-thread-list-test-helpers.js +5 -2
- package/dist/tests/remote-thread-list-test-helpers.js.map +1 -1
- package/dist/types/attachment.d.ts +2 -1
- package/dist/types/attachment.d.ts.map +1 -1
- package/dist/types/attachment.js +6 -0
- package/dist/types/attachment.js.map +1 -0
- package/dist/types/message.d.ts +1 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/dist/utils/data-url.d.ts +9 -0
- package/dist/utils/data-url.d.ts.map +1 -0
- package/dist/utils/data-url.js +14 -0
- package/dist/utils/data-url.js.map +1 -0
- package/dist/utils/notify-event-listeners.d.ts +6 -0
- package/dist/utils/notify-event-listeners.d.ts.map +1 -0
- package/dist/utils/notify-event-listeners.js +16 -0
- package/dist/utils/notify-event-listeners.js.map +1 -0
- package/package.json +10 -9
- package/src/adapters/speech.test.ts +88 -0
- package/src/adapters/speech.ts +17 -2
- package/src/adapters/voice.test.ts +130 -0
- package/src/adapters/voice.ts +26 -6
- package/src/internal.ts +5 -0
- package/src/model-context/frame/host.test.ts +100 -0
- package/src/model-context/frame/host.ts +10 -0
- package/src/model-context/frame/provider.test.ts +83 -0
- package/src/model-context/frame/provider.ts +1 -0
- package/src/react/adapters/LocalStorageThreadListAdapter.test.ts +82 -0
- package/src/react/adapters/LocalStorageThreadListAdapter.tsx +94 -54
- package/src/react/primitive-hooks/useActionBarCopy.test.ts +79 -0
- package/src/react/primitive-hooks/useActionBarCopy.ts +4 -3
- package/src/react/primitives/chainOfThought/ChainOfThoughtParts.tsx +1 -3
- package/src/react/primitives/message/MessageParts.tsx +48 -35
- package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +19 -2
- package/src/react/providers/PartByIndexProvider.tsx +16 -2
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.test.tsx +18 -0
- package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +1 -0
- package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +48 -7
- package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.test.tsx +69 -0
- package/src/react/runtimes/cloud/AssistantCloudThreadHistoryAdapter.ts +1 -1
- package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.test.ts +16 -0
- package/src/react/runtimes/cloud/CloudFileAttachmentAdapter.ts +2 -1
- package/src/react/runtimes/external-message-converter.test.tsx +21 -0
- package/src/react/runtimes/external-message-converter.ts +1 -1
- package/src/react/runtimes/useLocalRuntime.test.tsx +56 -0
- package/src/react/runtimes/useLocalRuntime.ts +13 -1
- package/src/runtime/api/message-runtime.test.ts +81 -0
- package/src/runtime/api/message-runtime.ts +4 -6
- package/src/runtime/api/thread-list-item-runtime.ts +6 -1
- package/src/runtime/base/base-composer-runtime-core.ts +77 -31
- package/src/runtime/base/base-thread-runtime-core.ts +2 -1
- package/src/runtime/base/default-edit-composer-runtime-core.ts +4 -2
- package/src/runtime/base/default-thread-composer-runtime-core.ts +2 -2
- package/src/runtime/utils/thread-message-like.ts +1 -0
- package/src/runtimes/local/local-thread-runtime-core.test.ts +150 -1
- package/src/runtimes/local/local-thread-runtime-core.ts +4 -2
- package/src/runtimes/remote-thread-list/optimistic-state.test.ts +63 -0
- package/src/runtimes/remote-thread-list/optimistic-state.ts +49 -17
- package/src/runtimes/remote-thread-list/types.ts +1 -1
- package/src/runtimes/tool-invocations/EDGE_CASES.md +13 -19
- package/src/runtimes/tool-invocations/ToolInvocationTracker.test.ts +127 -6
- package/src/runtimes/tool-invocations/ToolInvocationTracker.ts +4 -2
- package/src/subscribable/subscribable.test.ts +58 -0
- package/src/subscribable/subscribable.ts +13 -3
- package/src/tests/OptimisticState-list-race.test.ts +33 -0
- package/src/tests/RemoteThreadListThreadListRuntimeCore-switchToThread-order.test.ts +223 -0
- package/src/tests/base-composer-runtime-core-addAttachment.test.ts +44 -0
- package/src/tests/base-composer-runtime-core-send.test.ts +433 -5
- package/src/tests/default-edit-composer-runtime-core.test.ts +43 -5
- package/src/tests/event-subscription-listener-errors.test.ts +205 -0
- package/src/tests/remote-thread-list-test-helpers.ts +14 -7
- package/src/tests/thread-message-like.test.ts +57 -0
- package/src/types/attachment.ts +5 -0
- package/src/types/message.ts +1 -0
- package/src/utils/data-url.test.ts +50 -0
- package/src/utils/data-url.ts +9 -0
- 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:
|
|
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
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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<
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
transform.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
for (const
|
|
95
|
-
|
|
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(
|
|
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;
|
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
952
|
-
//
|
|
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:
|
|
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
|
+
});
|