@assistant-ui/react 0.14.14 → 0.14.16
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/README.md +5 -1
- package/dist/client/ExternalThread.d.ts +2 -12
- package/dist/client/ExternalThread.d.ts.map +1 -1
- package/dist/client/ExternalThread.js +30 -29
- package/dist/client/ExternalThread.js.map +1 -1
- package/dist/client/InMemoryThreadList.d.ts.map +1 -1
- package/dist/client/InMemoryThreadList.js +11 -10
- package/dist/client/InMemoryThreadList.js.map +1 -1
- package/dist/client/SingleThreadList.d.ts.map +1 -1
- package/dist/client/SingleThreadList.js +9 -8
- package/dist/client/SingleThreadList.js.map +1 -1
- package/dist/context/providers/ThreadViewportProvider.js +1 -1
- package/dist/context/providers/ThreadViewportProvider.js.map +1 -1
- package/dist/context/react/ThreadViewportContext.js +1 -1
- package/dist/context/react/utils/createContextHook.js +1 -1
- package/dist/context/react/utils/ensureBinding.js.map +1 -1
- package/dist/context/react/utils/useRuntimeState.js +1 -1
- package/dist/context/stores/ThreadViewport.js.map +1 -1
- package/dist/devtools/DevToolsHooks.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +3 -3
- package/dist/legacy-runtime/AssistantRuntimeProvider.js +1 -1
- package/dist/legacy-runtime/cloud/auiV0.js +1 -1
- package/dist/legacy-runtime/hooks/AssistantContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/AttachmentContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/ComposerContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/MessageContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/MessagePartContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/ThreadContext.js +1 -1
- package/dist/legacy-runtime/hooks/ThreadContext.js.map +1 -1
- package/dist/legacy-runtime/hooks/ThreadListItemContext.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/commandQueue.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/replayBoundaryStream.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/runManager.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useConvertedState.js +1 -1
- package/dist/legacy-runtime/runtime-cores/assistant-transport/useLatestRef.js +1 -1
- package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppRenderer.js +7 -7
- package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
- package/dist/mcp-apps/McpAppsRemoteHost.js +5 -4
- package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
- package/dist/mcp-apps/app-frame.d.ts +1 -1
- package/dist/mcp-apps/app-frame.d.ts.map +1 -1
- package/dist/mcp-apps/app-frame.js +82 -104
- package/dist/mcp-apps/app-frame.js.map +1 -1
- package/dist/mcp-apps/bridge.d.ts +3 -3
- package/dist/mcp-apps/bridge.d.ts.map +1 -1
- package/dist/mcp-apps/bridge.js +35 -10
- package/dist/mcp-apps/bridge.js.map +1 -1
- package/dist/mcp-apps/types.d.ts +2 -12
- package/dist/mcp-apps/types.d.ts.map +1 -1
- package/dist/mcp-apps/types.js.map +1 -1
- package/dist/model-context/frame/useAssistantFrameHost.js +1 -1
- package/dist/model-context/makeAssistantVisible.js +1 -1
- package/dist/model-context/makeAssistantVisible.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarCopy.js +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js +1 -1
- package/dist/primitives/actionBar/ActionBarExportMarkdown.js.map +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackNegative.js +1 -1
- package/dist/primitives/actionBar/ActionBarFeedbackPositive.js +1 -1
- package/dist/primitives/actionBar/ActionBarInteractionContext.js +1 -1
- package/dist/primitives/actionBar/ActionBarRoot.js +1 -1
- package/dist/primitives/actionBar/ActionBarStopSpeaking.js +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreContent.js +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreItem.js +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreSeparator.js +1 -1
- package/dist/primitives/actionBarMore/ActionBarMoreTrigger.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalAnchor.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalContent.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalRoot.js +1 -1
- package/dist/primitives/assistantModal/AssistantModalTrigger.js +1 -1
- package/dist/primitives/attachment/AttachmentRemove.js +1 -1
- package/dist/primitives/attachment/AttachmentRemove.js.map +1 -1
- package/dist/primitives/attachment/AttachmentRoot.js +1 -1
- package/dist/primitives/attachment/AttachmentThumb.js +1 -1
- package/dist/primitives/branchPicker/BranchPickerRoot.js +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtAccordionTrigger.js.map +1 -1
- package/dist/primitives/chainOfThought/ChainOfThoughtRoot.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js +1 -1
- package/dist/primitives/composer/ComposerAddAttachment.js.map +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js +1 -1
- package/dist/primitives/composer/ComposerAttachmentDropzone.js.map +1 -1
- package/dist/primitives/composer/ComposerDictationTranscript.js +1 -1
- package/dist/primitives/composer/ComposerInput.js +1 -1
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/ComposerInputPluginContext.js +1 -1
- package/dist/primitives/composer/ComposerQuote.js +1 -1
- package/dist/primitives/composer/ComposerQuote.js.map +1 -1
- package/dist/primitives/composer/ComposerRoot.js +1 -1
- package/dist/primitives/composer/ComposerSend.js +1 -1
- package/dist/primitives/composer/ComposerStopDictation.js +1 -1
- package/dist/primitives/composer/ComposerStopDictation.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopover.js +2 -2
- package/dist/primitives/composer/trigger/TriggerPopover.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverAction.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverBack.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverCategories.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverDirective.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverItems.js +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js +8 -7
- package/dist/primitives/composer/trigger/TriggerPopoverResource.js.map +1 -1
- package/dist/primitives/composer/trigger/TriggerPopoverRootContext.js +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerDetectionResource.js +5 -4
- package/dist/primitives/composer/trigger/triggerDetectionResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js +8 -7
- package/dist/primitives/composer/trigger/triggerKeyboardResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerNavigationResource.js +13 -12
- package/dist/primitives/composer/trigger/triggerNavigationResource.js.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.d.ts.map +1 -1
- package/dist/primitives/composer/trigger/triggerSelectionResource.js +7 -6
- package/dist/primitives/composer/trigger/triggerSelectionResource.js.map +1 -1
- package/dist/primitives/error/ErrorMessage.js +1 -1
- package/dist/primitives/error/ErrorRoot.js +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js +1 -1
- package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
- package/dist/primitives/message/MessageRoot.js +1 -1
- package/dist/primitives/message/MessageRoot.js.map +1 -1
- package/dist/primitives/messagePart/MessagePartImage.js +1 -1
- package/dist/primitives/messagePart/MessagePartText.js +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.js +1 -1
- package/dist/primitives/queueItem/QueueItemRemove.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.js +1 -1
- package/dist/primitives/queueItem/QueueItemSteer.js.map +1 -1
- package/dist/primitives/queueItem/QueueItemText.js +1 -1
- package/dist/primitives/reasoning/useScrollLock.js +1 -1
- package/dist/primitives/reasoning/useScrollLock.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarQuote.js.map +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js +1 -1
- package/dist/primitives/selectionToolbar/SelectionToolbarRoot.js.map +1 -1
- package/dist/primitives/suggestion/SuggestionDescription.js +1 -1
- package/dist/primitives/suggestion/SuggestionTitle.js +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js +1 -1
- package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
- package/dist/primitives/thread/ThreadRoot.js +1 -1
- package/dist/primitives/thread/ThreadScrollToBottom.js +1 -1
- package/dist/primitives/thread/ThreadScrollToBottom.js.map +1 -1
- package/dist/primitives/thread/ThreadViewport.js +1 -1
- package/dist/primitives/thread/ThreadViewport.js.map +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js +1 -1
- package/dist/primitives/thread/ThreadViewportFooter.js.map +1 -1
- package/dist/primitives/thread/topAnchor/topAnchorTurn.js.map +1 -1
- package/dist/primitives/thread/topAnchor/topAnchorUtils.js.map +1 -1
- package/dist/primitives/thread/topAnchor/useTopAnchorReserve.js +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js +1 -1
- package/dist/primitives/thread/useThreadViewportAutoScroll.js.map +1 -1
- package/dist/primitives/threadList/ThreadListNew.js +1 -1
- package/dist/primitives/threadList/ThreadListRoot.js +1 -1
- package/dist/primitives/threadListItem/ThreadListItemRoot.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreContent.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreItem.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreSeparator.js +1 -1
- package/dist/primitives/threadListItemMore/ThreadListItemMoreTrigger.js +1 -1
- package/dist/sandbox-host/SandboxHost.d.ts +50 -0
- package/dist/sandbox-host/SandboxHost.d.ts.map +1 -0
- package/dist/sandbox-host/SandboxHost.js +85 -0
- package/dist/sandbox-host/SandboxHost.js.map +1 -0
- package/dist/unstable/useMentionAdapter.js +1 -1
- package/dist/unstable/useMentionAdapter.js.map +1 -1
- package/dist/unstable/useSlashCommandAdapter.js +1 -1
- package/dist/unstable/useSlashCommandAdapter.js.map +1 -1
- package/dist/utils/Primitive.js +1 -1
- package/dist/utils/createActionButton.js +1 -1
- package/dist/utils/createActionButton.js.map +1 -1
- package/dist/utils/hooks/useManagedRef.js +1 -1
- package/dist/utils/hooks/useMediaQuery.js +1 -1
- package/dist/utils/hooks/useMediaQuery.js.map +1 -1
- package/dist/utils/hooks/useOnResizeContent.js +1 -1
- package/dist/utils/hooks/useOnScrollToBottom.js +1 -1
- package/dist/utils/hooks/useSizeHandle.js +1 -1
- package/dist/utils/json/is-json.js.map +1 -1
- package/dist/utils/smooth/SmoothContext.js +1 -1
- package/dist/utils/smooth/SmoothContext.js.map +1 -1
- package/dist/utils/smooth/useSmooth.js +1 -1
- package/dist/utils/smooth/useSmooth.js.map +1 -1
- package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
- package/dist/utils/useToolArgsFieldStatus.d.ts.map +1 -1
- package/package.json +48 -40
- package/src/client/ExternalThread.ts +484 -515
- package/src/client/InMemoryThreadList.ts +153 -162
- package/src/client/SingleThreadList.ts +87 -84
- package/src/context/providers/ThreadViewportProvider.tsx +2 -2
- package/src/index.ts +8 -1
- package/src/mcp-apps/McpAppRenderer.tsx +28 -35
- package/src/mcp-apps/McpAppsRemoteHost.ts +25 -24
- package/src/mcp-apps/app-frame.tsx +100 -141
- package/src/mcp-apps/bridge.test.ts +100 -60
- package/src/mcp-apps/bridge.ts +43 -21
- package/src/mcp-apps/types.ts +2 -12
- package/src/primitives/composer/trigger/TriggerPopover.tsx +1 -1
- package/src/primitives/composer/trigger/TriggerPopoverResource.ts +75 -76
- package/src/primitives/composer/trigger/triggerDetectionResource.ts +6 -5
- package/src/primitives/composer/trigger/triggerKeyboardResource.ts +9 -13
- package/src/primitives/composer/trigger/triggerNavigationResource.ts +14 -19
- package/src/primitives/composer/trigger/triggerSelectionResource.ts +8 -7
- package/src/sandbox-host/SandboxHost.test.tsx +231 -0
- package/src/sandbox-host/SandboxHost.tsx +185 -0
- package/src/tests/local-runtime-queue.test.tsx +305 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
const { renderHtmlMock } = vi.hoisted(() => ({ renderHtmlMock: vi.fn() }));
|
|
7
|
+
|
|
8
|
+
vi.mock("safe-content-frame", () => ({
|
|
9
|
+
SafeContentFrame: class {
|
|
10
|
+
renderHtml = renderHtmlMock;
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
SandboxHost,
|
|
16
|
+
isSandboxFrameMessage,
|
|
17
|
+
type SandboxBridge,
|
|
18
|
+
type SandboxHostApi,
|
|
19
|
+
} from "./SandboxHost";
|
|
20
|
+
|
|
21
|
+
const validData = { jsonrpc: "2.0", method: "x" };
|
|
22
|
+
|
|
23
|
+
function makeFrame() {
|
|
24
|
+
const iframe = document.createElement("iframe");
|
|
25
|
+
document.body.appendChild(iframe);
|
|
26
|
+
return { iframe, origin: "https://app.example" };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fakeRendered() {
|
|
30
|
+
const iframe = document.createElement("iframe");
|
|
31
|
+
document.body.appendChild(iframe);
|
|
32
|
+
return {
|
|
33
|
+
iframe,
|
|
34
|
+
origin: "https://fake.scf.test",
|
|
35
|
+
sendMessage: vi.fn(),
|
|
36
|
+
dispose: vi.fn(),
|
|
37
|
+
fullyLoadedPromiseWithTimeout: vi.fn(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const flush = () =>
|
|
42
|
+
act(async () => {
|
|
43
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("isSandboxFrameMessage", () => {
|
|
47
|
+
it("accepts a message from the frame's contentWindow at its origin", () => {
|
|
48
|
+
const frame = makeFrame();
|
|
49
|
+
const event = new MessageEvent("message", {
|
|
50
|
+
data: validData,
|
|
51
|
+
origin: frame.origin,
|
|
52
|
+
source: frame.iframe.contentWindow,
|
|
53
|
+
});
|
|
54
|
+
expect(isSandboxFrameMessage(event, frame)).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("rejects a message from a different origin", () => {
|
|
58
|
+
const frame = makeFrame();
|
|
59
|
+
const event = new MessageEvent("message", {
|
|
60
|
+
data: validData,
|
|
61
|
+
origin: "https://attacker.example",
|
|
62
|
+
source: frame.iframe.contentWindow,
|
|
63
|
+
});
|
|
64
|
+
expect(isSandboxFrameMessage(event, frame)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects a message from a different source window", () => {
|
|
68
|
+
const frame = makeFrame();
|
|
69
|
+
const event = new MessageEvent("message", {
|
|
70
|
+
data: validData,
|
|
71
|
+
origin: frame.origin,
|
|
72
|
+
source: window,
|
|
73
|
+
});
|
|
74
|
+
expect(isSandboxFrameMessage(event, frame)).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("SandboxHost", () => {
|
|
79
|
+
let container: HTMLDivElement;
|
|
80
|
+
let root: Root;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
container = document.createElement("div");
|
|
84
|
+
document.body.appendChild(container);
|
|
85
|
+
root = createRoot(container);
|
|
86
|
+
renderHtmlMock.mockReset();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
afterEach(() => {
|
|
90
|
+
act(() => {
|
|
91
|
+
try {
|
|
92
|
+
root.unmount();
|
|
93
|
+
} catch {
|
|
94
|
+
// already unmounted by the test
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
container.remove();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("delivers only frame-validated messages to the bridge", async () => {
|
|
101
|
+
const rendered = fakeRendered();
|
|
102
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
103
|
+
const onMessage = vi.fn();
|
|
104
|
+
const bridge: SandboxBridge = { onMessage, dispose: vi.fn() };
|
|
105
|
+
|
|
106
|
+
await act(async () => {
|
|
107
|
+
root.render(
|
|
108
|
+
<SandboxHost
|
|
109
|
+
content={{ html: "" }}
|
|
110
|
+
contentKey="k"
|
|
111
|
+
createBridge={() => bridge}
|
|
112
|
+
/>,
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
await flush();
|
|
116
|
+
|
|
117
|
+
window.dispatchEvent(
|
|
118
|
+
new MessageEvent("message", {
|
|
119
|
+
data: validData,
|
|
120
|
+
origin: rendered.origin,
|
|
121
|
+
source: rendered.iframe.contentWindow,
|
|
122
|
+
}),
|
|
123
|
+
);
|
|
124
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
125
|
+
|
|
126
|
+
window.dispatchEvent(
|
|
127
|
+
new MessageEvent("message", {
|
|
128
|
+
data: validData,
|
|
129
|
+
origin: "https://attacker.example",
|
|
130
|
+
source: rendered.iframe.contentWindow,
|
|
131
|
+
}),
|
|
132
|
+
);
|
|
133
|
+
window.dispatchEvent(
|
|
134
|
+
new MessageEvent("message", {
|
|
135
|
+
data: validData,
|
|
136
|
+
origin: rendered.origin,
|
|
137
|
+
source: window,
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(onMessage).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("clamps the bridge-reported height to maxHeight and ignores invalid values", async () => {
|
|
144
|
+
const rendered = fakeRendered();
|
|
145
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
146
|
+
let host!: SandboxHostApi;
|
|
147
|
+
const bridge: SandboxBridge = { onMessage: vi.fn(), dispose: vi.fn() };
|
|
148
|
+
|
|
149
|
+
await act(async () => {
|
|
150
|
+
root.render(
|
|
151
|
+
<SandboxHost
|
|
152
|
+
content={{ html: "" }}
|
|
153
|
+
contentKey="k"
|
|
154
|
+
maxHeight={800}
|
|
155
|
+
createBridge={(_frame, h) => {
|
|
156
|
+
host = h;
|
|
157
|
+
return bridge;
|
|
158
|
+
}}
|
|
159
|
+
/>,
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
await flush();
|
|
163
|
+
|
|
164
|
+
const div = container.firstElementChild as HTMLDivElement;
|
|
165
|
+
await act(async () => host.setHeight(200));
|
|
166
|
+
expect(div.style.height).toBe("200px");
|
|
167
|
+
await act(async () => host.setHeight(5000));
|
|
168
|
+
expect(div.style.height).toBe("800px");
|
|
169
|
+
await act(async () => host.setHeight(0));
|
|
170
|
+
expect(div.style.height).toBe("800px");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("disposes the bridge before the frame and detaches the listener on unmount", async () => {
|
|
174
|
+
const rendered = fakeRendered();
|
|
175
|
+
renderHtmlMock.mockResolvedValue(rendered);
|
|
176
|
+
const order: string[] = [];
|
|
177
|
+
const onMessage = vi.fn();
|
|
178
|
+
const bridge: SandboxBridge = {
|
|
179
|
+
onMessage,
|
|
180
|
+
dispose: vi.fn(() => order.push("bridge")),
|
|
181
|
+
};
|
|
182
|
+
rendered.dispose = vi.fn(() => order.push("frame"));
|
|
183
|
+
|
|
184
|
+
await act(async () => {
|
|
185
|
+
root.render(
|
|
186
|
+
<SandboxHost
|
|
187
|
+
content={{ html: "" }}
|
|
188
|
+
contentKey="k"
|
|
189
|
+
createBridge={() => bridge}
|
|
190
|
+
/>,
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
await flush();
|
|
194
|
+
|
|
195
|
+
await act(async () => {
|
|
196
|
+
root.unmount();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
expect(order).toEqual(["bridge", "frame"]);
|
|
200
|
+
|
|
201
|
+
onMessage.mockClear();
|
|
202
|
+
window.dispatchEvent(
|
|
203
|
+
new MessageEvent("message", {
|
|
204
|
+
data: validData,
|
|
205
|
+
origin: rendered.origin,
|
|
206
|
+
source: rendered.iframe.contentWindow,
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("calls onError when rendering rejects", async () => {
|
|
213
|
+
renderHtmlMock.mockRejectedValue(new Error("boom"));
|
|
214
|
+
const onError = vi.fn();
|
|
215
|
+
|
|
216
|
+
await act(async () => {
|
|
217
|
+
root.render(
|
|
218
|
+
<SandboxHost
|
|
219
|
+
content={{ html: "" }}
|
|
220
|
+
contentKey="k"
|
|
221
|
+
createBridge={() => ({ onMessage: vi.fn(), dispose: vi.fn() })}
|
|
222
|
+
onError={onError}
|
|
223
|
+
/>,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
await flush();
|
|
227
|
+
|
|
228
|
+
expect(onError).toHaveBeenCalledTimes(1);
|
|
229
|
+
expect(onError.mock.calls[0]![0].message).toBe("boom");
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
type RenderedFrame,
|
|
6
|
+
SafeContentFrame,
|
|
7
|
+
type SandboxOption,
|
|
8
|
+
} from "safe-content-frame";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PRODUCT = "assistant-ui-sandbox";
|
|
11
|
+
const DEFAULT_MAX_HEIGHT = 800;
|
|
12
|
+
|
|
13
|
+
export type SandboxHostConfig = {
|
|
14
|
+
sandbox?: SandboxOption[];
|
|
15
|
+
useShadowDom?: boolean;
|
|
16
|
+
enableBrowserCaching?: boolean;
|
|
17
|
+
salt?: string;
|
|
18
|
+
product?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
style?: CSSProperties;
|
|
21
|
+
unsafeDocumentWrite?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SandboxHostFrame = Pick<
|
|
25
|
+
RenderedFrame,
|
|
26
|
+
"iframe" | "origin" | "sendMessage"
|
|
27
|
+
>;
|
|
28
|
+
|
|
29
|
+
export type SandboxHostApi = {
|
|
30
|
+
setHeight: (height: number) => void;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type SandboxBridge = {
|
|
34
|
+
onMessage: (event: MessageEvent) => void;
|
|
35
|
+
dispose: () => void;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type SandboxContent = { html: string };
|
|
39
|
+
|
|
40
|
+
export type SandboxHostProps = {
|
|
41
|
+
content: SandboxContent;
|
|
42
|
+
contentKey: string;
|
|
43
|
+
sandbox?: SandboxHostConfig | undefined;
|
|
44
|
+
maxHeight?: number | undefined;
|
|
45
|
+
createBridge: (
|
|
46
|
+
frame: SandboxHostFrame,
|
|
47
|
+
host: SandboxHostApi,
|
|
48
|
+
) => SandboxBridge;
|
|
49
|
+
onError?: ((error: Error) => void) | undefined;
|
|
50
|
+
containerProps?: Record<string, string | undefined> | undefined;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function isSandboxFrameMessage(
|
|
54
|
+
event: MessageEvent,
|
|
55
|
+
frame: { iframe: HTMLIFrameElement; origin: string },
|
|
56
|
+
): boolean {
|
|
57
|
+
return (
|
|
58
|
+
event.source === frame.iframe.contentWindow && event.origin === frame.origin
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type LiveSnapshot = {
|
|
63
|
+
content: SandboxContent;
|
|
64
|
+
sandbox: SandboxHostConfig | undefined;
|
|
65
|
+
createBridge: SandboxHostProps["createBridge"];
|
|
66
|
+
onError: SandboxHostProps["onError"];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function SandboxHost({
|
|
70
|
+
content,
|
|
71
|
+
contentKey,
|
|
72
|
+
sandbox,
|
|
73
|
+
maxHeight = DEFAULT_MAX_HEIGHT,
|
|
74
|
+
createBridge,
|
|
75
|
+
onError,
|
|
76
|
+
containerProps,
|
|
77
|
+
}: SandboxHostProps) {
|
|
78
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
79
|
+
const [contentHeight, setContentHeight] = useState<number | undefined>(
|
|
80
|
+
undefined,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const liveRef = useRef<LiveSnapshot>(null!);
|
|
84
|
+
liveRef.current = { content, sandbox, createBridge, onError };
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
const container = containerRef.current;
|
|
88
|
+
if (!container) return;
|
|
89
|
+
|
|
90
|
+
let cancelled = false;
|
|
91
|
+
let frame: RenderedFrame | null = null;
|
|
92
|
+
let bridge: SandboxBridge | null = null;
|
|
93
|
+
let onMessage: ((event: MessageEvent) => void) | null = null;
|
|
94
|
+
|
|
95
|
+
const { content: liveContent, sandbox: sb } = liveRef.current;
|
|
96
|
+
|
|
97
|
+
const scf = new SafeContentFrame(sb?.product ?? DEFAULT_PRODUCT, {
|
|
98
|
+
...(sb?.sandbox !== undefined && { sandbox: sb.sandbox }),
|
|
99
|
+
...(sb?.useShadowDom !== undefined && { useShadowDom: sb.useShadowDom }),
|
|
100
|
+
...(sb?.enableBrowserCaching !== undefined && {
|
|
101
|
+
enableBrowserCaching: sb.enableBrowserCaching,
|
|
102
|
+
}),
|
|
103
|
+
...(sb?.salt !== undefined && { salt: sb.salt }),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const renderOpts =
|
|
107
|
+
sb?.unsafeDocumentWrite !== undefined
|
|
108
|
+
? { unsafeDocumentWrite: sb.unsafeDocumentWrite }
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
111
|
+
scf
|
|
112
|
+
.renderHtml(liveContent.html, container, renderOpts)
|
|
113
|
+
.then((rendered) => {
|
|
114
|
+
if (cancelled) {
|
|
115
|
+
rendered.dispose();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
frame = rendered;
|
|
119
|
+
|
|
120
|
+
const hostApi: SandboxHostApi = {
|
|
121
|
+
setHeight: (height) => {
|
|
122
|
+
if (
|
|
123
|
+
typeof height === "number" &&
|
|
124
|
+
Number.isFinite(height) &&
|
|
125
|
+
height > 0
|
|
126
|
+
) {
|
|
127
|
+
setContentHeight(height);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
bridge = liveRef.current.createBridge(
|
|
133
|
+
{
|
|
134
|
+
iframe: rendered.iframe,
|
|
135
|
+
origin: rendered.origin,
|
|
136
|
+
sendMessage: rendered.sendMessage,
|
|
137
|
+
},
|
|
138
|
+
hostApi,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// Single owner of the window listener; the cross-origin guard runs
|
|
142
|
+
// here so the bridge only sees frame-validated messages.
|
|
143
|
+
onMessage = (event) => {
|
|
144
|
+
if (!isSandboxFrameMessage(event, rendered)) return;
|
|
145
|
+
bridge?.onMessage(event);
|
|
146
|
+
};
|
|
147
|
+
window.addEventListener("message", onMessage);
|
|
148
|
+
})
|
|
149
|
+
.catch((err) => {
|
|
150
|
+
liveRef.current.onError?.(
|
|
151
|
+
err instanceof Error ? err : new Error(String(err)),
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return () => {
|
|
156
|
+
cancelled = true;
|
|
157
|
+
if (onMessage) {
|
|
158
|
+
window.removeEventListener("message", onMessage);
|
|
159
|
+
onMessage = null;
|
|
160
|
+
}
|
|
161
|
+
bridge?.dispose();
|
|
162
|
+
bridge = null;
|
|
163
|
+
frame?.dispose();
|
|
164
|
+
frame = null;
|
|
165
|
+
setContentHeight(undefined);
|
|
166
|
+
};
|
|
167
|
+
// oxlint-disable-next-line react/exhaustive-deps -- re-init only on contentKey change; live values flow through liveRef
|
|
168
|
+
}, [contentKey]);
|
|
169
|
+
|
|
170
|
+
const resolvedHeight =
|
|
171
|
+
contentHeight != null ? Math.min(contentHeight, maxHeight) : undefined;
|
|
172
|
+
const mergedStyle =
|
|
173
|
+
resolvedHeight != null
|
|
174
|
+
? { ...sandbox?.style, height: resolvedHeight }
|
|
175
|
+
: sandbox?.style;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div
|
|
179
|
+
{...containerProps}
|
|
180
|
+
ref={containerRef}
|
|
181
|
+
className={sandbox?.className}
|
|
182
|
+
style={mergedStyle}
|
|
183
|
+
/>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { render, act } from "@testing-library/react";
|
|
4
|
+
import type { FC } from "react";
|
|
5
|
+
import { describe, it, expect } from "vitest";
|
|
6
|
+
import { useAui } from "@assistant-ui/store";
|
|
7
|
+
import { AssistantRuntimeProvider } from "../context";
|
|
8
|
+
import { useLocalRuntime } from "../legacy-runtime/runtime-cores/local/useLocalRuntime";
|
|
9
|
+
import type { ChatModelAdapter } from "../legacy-runtime/runtime-cores/local/ChatModelAdapter";
|
|
10
|
+
|
|
11
|
+
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
12
|
+
|
|
13
|
+
const createCountingAdapter = () => {
|
|
14
|
+
const releases: Array<() => void> = [];
|
|
15
|
+
let runCount = 0;
|
|
16
|
+
const adapter: ChatModelAdapter = {
|
|
17
|
+
async *run({ abortSignal }) {
|
|
18
|
+
runCount++;
|
|
19
|
+
await new Promise<void>((resolve) => {
|
|
20
|
+
releases.push(resolve);
|
|
21
|
+
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
|
22
|
+
});
|
|
23
|
+
yield { content: [{ type: "text", text: "done" }] };
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
return { adapter, releases, getRunCount: () => runCount };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const userTexts = (aui: ReturnType<typeof useAui>) =>
|
|
30
|
+
aui
|
|
31
|
+
.thread()
|
|
32
|
+
.getState()
|
|
33
|
+
.messages.filter((m) => m.role === "user")
|
|
34
|
+
.map((m) =>
|
|
35
|
+
m.content.map((p) => (p.type === "text" ? p.text : "")).join(""),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const renderWithRuntime = (adapter: ChatModelAdapter, enableQueue: boolean) => {
|
|
39
|
+
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
40
|
+
const Capture: FC = () => {
|
|
41
|
+
captured.aui = useAui();
|
|
42
|
+
return null;
|
|
43
|
+
};
|
|
44
|
+
const App: FC = () => {
|
|
45
|
+
const runtime = useLocalRuntime(adapter, {
|
|
46
|
+
unstable_enableMessageQueue: enableQueue,
|
|
47
|
+
});
|
|
48
|
+
return (
|
|
49
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
50
|
+
<Capture />
|
|
51
|
+
</AssistantRuntimeProvider>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
render(<App />);
|
|
55
|
+
return captured.aui!;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const send = async (aui: ReturnType<typeof useAui>, text: string) => {
|
|
59
|
+
await act(async () => {
|
|
60
|
+
aui.thread().composer().setText(text);
|
|
61
|
+
aui.thread().composer().send();
|
|
62
|
+
await flush();
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe("local runtime message queue", () => {
|
|
67
|
+
it("buffers a send while running and flushes it when the run ends", async () => {
|
|
68
|
+
const { adapter, releases } = createCountingAdapter();
|
|
69
|
+
const aui = renderWithRuntime(adapter, true);
|
|
70
|
+
|
|
71
|
+
await send(aui, "first");
|
|
72
|
+
expect(aui.thread().getState().isRunning).toBe(true);
|
|
73
|
+
expect(aui.thread().getState().capabilities.queue).toBe(true);
|
|
74
|
+
|
|
75
|
+
await send(aui, "second");
|
|
76
|
+
expect(
|
|
77
|
+
aui
|
|
78
|
+
.thread()
|
|
79
|
+
.composer()
|
|
80
|
+
.getState()
|
|
81
|
+
.queue.map((q) => q.prompt),
|
|
82
|
+
).toEqual(["second"]);
|
|
83
|
+
expect(userTexts(aui)).toEqual(["first"]);
|
|
84
|
+
|
|
85
|
+
await act(async () => {
|
|
86
|
+
releases[0]!();
|
|
87
|
+
await flush();
|
|
88
|
+
await flush();
|
|
89
|
+
});
|
|
90
|
+
expect(aui.thread().composer().getState().queue).toEqual([]);
|
|
91
|
+
expect(userTexts(aui)).toContain("second");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("drains two queued items in separate runs, not all at once", async () => {
|
|
95
|
+
const { adapter, releases, getRunCount } = createCountingAdapter();
|
|
96
|
+
const aui = renderWithRuntime(adapter, true);
|
|
97
|
+
|
|
98
|
+
await send(aui, "first");
|
|
99
|
+
expect(getRunCount()).toBe(1);
|
|
100
|
+
|
|
101
|
+
await send(aui, "a");
|
|
102
|
+
await send(aui, "b");
|
|
103
|
+
expect(aui.thread().composer().getState().queue).toHaveLength(2);
|
|
104
|
+
|
|
105
|
+
await act(async () => {
|
|
106
|
+
releases[0]!();
|
|
107
|
+
await flush();
|
|
108
|
+
await flush();
|
|
109
|
+
});
|
|
110
|
+
expect(getRunCount()).toBe(2);
|
|
111
|
+
expect(
|
|
112
|
+
aui
|
|
113
|
+
.thread()
|
|
114
|
+
.composer()
|
|
115
|
+
.getState()
|
|
116
|
+
.queue.map((q) => q.prompt),
|
|
117
|
+
).toEqual(["b"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("queueItem(index).remove() drops a queued message", async () => {
|
|
121
|
+
const { adapter } = createCountingAdapter();
|
|
122
|
+
const aui = renderWithRuntime(adapter, true);
|
|
123
|
+
|
|
124
|
+
await send(aui, "first");
|
|
125
|
+
await send(aui, "a");
|
|
126
|
+
await send(aui, "b");
|
|
127
|
+
expect(aui.thread().composer().getState().queue).toHaveLength(2);
|
|
128
|
+
|
|
129
|
+
await act(async () => {
|
|
130
|
+
aui.thread().composer().queueItem({ index: 0 }).remove();
|
|
131
|
+
await flush();
|
|
132
|
+
});
|
|
133
|
+
const queue = aui.thread().composer().getState().queue;
|
|
134
|
+
expect(queue).toHaveLength(1);
|
|
135
|
+
expect(queue[0]!.prompt).toBe("b");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("clears the queue when the run is cancelled, without flushing", async () => {
|
|
139
|
+
const { adapter, getRunCount } = createCountingAdapter();
|
|
140
|
+
const aui = renderWithRuntime(adapter, true);
|
|
141
|
+
|
|
142
|
+
await send(aui, "first");
|
|
143
|
+
await send(aui, "a");
|
|
144
|
+
await send(aui, "b");
|
|
145
|
+
expect(aui.thread().composer().getState().queue).toHaveLength(2);
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
aui.thread().cancelRun();
|
|
149
|
+
await flush();
|
|
150
|
+
await flush();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(aui.thread().composer().getState().queue).toEqual([]);
|
|
154
|
+
// cancelling must not start the next queued message
|
|
155
|
+
expect(getRunCount()).toBe(1);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("applies an edit instead of queuing it, dropping pending items", async () => {
|
|
159
|
+
const { adapter, releases } = createCountingAdapter();
|
|
160
|
+
const aui = renderWithRuntime(adapter, true);
|
|
161
|
+
|
|
162
|
+
await send(aui, "first");
|
|
163
|
+
await act(async () => {
|
|
164
|
+
releases[0]!();
|
|
165
|
+
await flush();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// start a second run and queue a message behind it
|
|
169
|
+
await send(aui, "second");
|
|
170
|
+
await send(aui, "queued");
|
|
171
|
+
expect(aui.thread().composer().getState().queue).toHaveLength(1);
|
|
172
|
+
|
|
173
|
+
// edit the first message while the run is in progress
|
|
174
|
+
await act(async () => {
|
|
175
|
+
const message = aui.thread().message({ index: 0 });
|
|
176
|
+
message.composer().beginEdit();
|
|
177
|
+
message.composer().setText("edited");
|
|
178
|
+
message.composer().send();
|
|
179
|
+
await flush();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// the edit is applied (branches the thread) and the stale queue is cleared
|
|
183
|
+
expect(aui.thread().composer().getState().queue).toEqual([]);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("buffers a send during a regenerate instead of interrupting it", async () => {
|
|
187
|
+
const { adapter, releases, getRunCount } = createCountingAdapter();
|
|
188
|
+
const aui = renderWithRuntime(adapter, true);
|
|
189
|
+
|
|
190
|
+
await send(aui, "first");
|
|
191
|
+
await act(async () => {
|
|
192
|
+
releases[0]!();
|
|
193
|
+
await flush();
|
|
194
|
+
});
|
|
195
|
+
expect(getRunCount()).toBe(1);
|
|
196
|
+
|
|
197
|
+
// regenerate the assistant message: a run started outside the queue
|
|
198
|
+
await act(async () => {
|
|
199
|
+
aui.thread().message({ index: 1 }).reload();
|
|
200
|
+
await flush();
|
|
201
|
+
});
|
|
202
|
+
expect(getRunCount()).toBe(2);
|
|
203
|
+
expect(aui.thread().getState().isRunning).toBe(true);
|
|
204
|
+
|
|
205
|
+
// sending now must buffer, not interrupt the regenerate
|
|
206
|
+
await act(async () => {
|
|
207
|
+
aui.thread().composer().setText("Y");
|
|
208
|
+
aui.thread().composer().send();
|
|
209
|
+
await flush();
|
|
210
|
+
});
|
|
211
|
+
expect(getRunCount()).toBe(2);
|
|
212
|
+
expect(
|
|
213
|
+
aui
|
|
214
|
+
.thread()
|
|
215
|
+
.composer()
|
|
216
|
+
.getState()
|
|
217
|
+
.queue.map((q) => q.prompt),
|
|
218
|
+
).toEqual(["Y"]);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("advances exactly once after a failed run, without deadlocking", async () => {
|
|
222
|
+
const releases: Array<() => void> = [];
|
|
223
|
+
let runCount = 0;
|
|
224
|
+
const adapter: ChatModelAdapter = {
|
|
225
|
+
async *run({ abortSignal }) {
|
|
226
|
+
runCount++;
|
|
227
|
+
if (runCount === 2) throw new Error("model boom");
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
releases.push(resolve);
|
|
230
|
+
abortSignal.addEventListener("abort", () => resolve(), {
|
|
231
|
+
once: true,
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
yield { content: [{ type: "text", text: "done" }] };
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const aui = renderWithRuntime(adapter, true);
|
|
238
|
+
|
|
239
|
+
await send(aui, "first");
|
|
240
|
+
await send(aui, "a");
|
|
241
|
+
await send(aui, "b");
|
|
242
|
+
expect(aui.thread().composer().getState().queue).toHaveLength(2);
|
|
243
|
+
|
|
244
|
+
// run 1 settles -> "a" drains (run 2 throws) -> "b" drains (run 3)
|
|
245
|
+
await act(async () => {
|
|
246
|
+
releases[0]!();
|
|
247
|
+
await flush();
|
|
248
|
+
await flush();
|
|
249
|
+
});
|
|
250
|
+
expect(runCount).toBe(3);
|
|
251
|
+
expect(aui.thread().composer().getState().queue).toEqual([]);
|
|
252
|
+
|
|
253
|
+
// "b" is running; a new send must buffer behind it, not interrupt it
|
|
254
|
+
await act(async () => {
|
|
255
|
+
aui.thread().composer().setText("c");
|
|
256
|
+
aui.thread().composer().send();
|
|
257
|
+
await flush();
|
|
258
|
+
});
|
|
259
|
+
expect(runCount).toBe(3);
|
|
260
|
+
expect(
|
|
261
|
+
aui
|
|
262
|
+
.thread()
|
|
263
|
+
.composer()
|
|
264
|
+
.getState()
|
|
265
|
+
.queue.map((q) => q.prompt),
|
|
266
|
+
).toEqual(["c"]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("does not expose the queue capability when the flag is off", async () => {
|
|
270
|
+
const { adapter } = createCountingAdapter();
|
|
271
|
+
const aui = renderWithRuntime(adapter, false);
|
|
272
|
+
expect(aui.thread().getState().capabilities.queue).toBe(false);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("tears the queue down when the flag is toggled off at runtime", async () => {
|
|
276
|
+
const { adapter } = createCountingAdapter();
|
|
277
|
+
const captured: { aui?: ReturnType<typeof useAui> } = {};
|
|
278
|
+
const Capture: FC = () => {
|
|
279
|
+
captured.aui = useAui();
|
|
280
|
+
return null;
|
|
281
|
+
};
|
|
282
|
+
const App: FC<{ enabled: boolean }> = ({ enabled }) => {
|
|
283
|
+
const runtime = useLocalRuntime(adapter, {
|
|
284
|
+
unstable_enableMessageQueue: enabled,
|
|
285
|
+
});
|
|
286
|
+
return (
|
|
287
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
288
|
+
<Capture />
|
|
289
|
+
</AssistantRuntimeProvider>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const { rerender } = render(<App enabled={true} />);
|
|
294
|
+
await act(async () => {
|
|
295
|
+
await flush();
|
|
296
|
+
});
|
|
297
|
+
expect(captured.aui!.thread().getState().capabilities.queue).toBe(true);
|
|
298
|
+
|
|
299
|
+
await act(async () => {
|
|
300
|
+
rerender(<App enabled={false} />);
|
|
301
|
+
await flush();
|
|
302
|
+
});
|
|
303
|
+
expect(captured.aui!.thread().getState().capabilities.queue).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
});
|