@assistant-ui/core 0.3.12 → 0.3.13
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/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/react/client/interactable-model-context.d.ts.map +1 -1
- package/dist/react/client/interactable-model-context.js +5 -1
- package/dist/react/client/interactable-model-context.js.map +1 -1
- package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
- package/dist/runtime/api/thread-runtime.js +5 -1
- package/dist/runtime/api/thread-runtime.js.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
- package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-composer-runtime-core.js +64 -11
- package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
- package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
- package/dist/runtime/base/base-thread-runtime-core.js +31 -0
- 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 +1 -6
- 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 +1 -4
- package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
- package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
- package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
- package/dist/runtime/queue/message-queue.js +23 -10
- package/dist/runtime/queue/message-queue.js.map +1 -1
- package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
- package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
- package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -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 +49 -13
- package/dist/runtimes/external-store/external-store-thread-runtime-core.js.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 +2 -1
- package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
- package/dist/store/clients/external-thread.d.ts +7 -0
- package/dist/store/clients/external-thread.d.ts.map +1 -1
- package/dist/store/clients/external-thread.js +460 -369
- package/dist/store/clients/external-thread.js.map +1 -1
- package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
- package/dist/store/clients/runtime-adapter.js +5 -2
- package/dist/store/clients/runtime-adapter.js.map +1 -1
- package/dist/store/clients/single-thread-list.d.ts.map +1 -1
- package/dist/store/clients/single-thread-list.js +31 -27
- package/dist/store/clients/single-thread-list.js.map +1 -1
- package/dist/store/clients/suggestions.d.ts +3 -1
- package/dist/store/clients/suggestions.d.ts.map +1 -1
- package/dist/store/clients/suggestions.js +65 -37
- package/dist/store/clients/suggestions.js.map +1 -1
- package/dist/store/index.d.ts +2 -2
- package/dist/store/index.js +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
- package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
- package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
- package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
- package/dist/store/scope-registration.d.ts +2 -2
- package/dist/store/scopes/suggestions.d.ts +6 -1
- package/dist/store/scopes/suggestions.d.ts.map +1 -1
- package/dist/store/scopes/thread.d.ts +17 -0
- package/dist/store/scopes/thread.d.ts.map +1 -1
- package/dist/types/error.d.ts +15 -1
- package/dist/types/error.d.ts.map +1 -1
- package/dist/types/error.js +18 -1
- package/dist/types/error.js.map +1 -1
- package/dist/types/message.d.ts +2 -0
- package/dist/types/message.d.ts.map +1 -1
- package/dist/types/message.js.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +1 -0
- package/src/react/client/interactable-model-context.ts +6 -1
- package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
- package/src/runtime/api/thread-runtime.ts +8 -1
- package/src/runtime/base/base-composer-runtime-core.ts +100 -8
- package/src/runtime/base/base-thread-runtime-core.ts +36 -0
- package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
- package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
- package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
- package/src/runtime/queue/message-queue.ts +26 -9
- package/src/runtimes/external-store/external-store-adapter.ts +7 -0
- package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
- package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
- package/src/store/clients/external-thread.ts +119 -16
- package/src/store/clients/runtime-adapter.ts +5 -2
- package/src/store/clients/single-thread-list.ts +2 -1
- package/src/store/clients/suggestions.ts +39 -13
- package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
- package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
- package/src/store/scopes/suggestions.ts +6 -0
- package/src/store/scopes/thread.ts +17 -0
- package/src/tests/append-interactable-snapshots.test.ts +350 -0
- package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
- package/src/tests/base-composer-runtime-core.test.ts +108 -1
- package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
- package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
- package/src/tests/external-thread-attachments.test.tsx +214 -0
- package/src/tests/external-thread-parity.test.tsx +54 -0
- package/src/tests/external-thread-refetch.test.tsx +117 -0
- package/src/tests/external-thread-suggestions.test.tsx +59 -0
- package/src/tests/message-queue.test.ts +26 -0
- package/src/tests/thread-message-like.test.ts +39 -0
- package/src/tests/thread-switch-events.test.tsx +136 -0
- package/src/types/error.ts +24 -0
- package/src/types/message.ts +2 -0
- package/src/tests/default-thread-composer-runtime-core.test.ts +0 -82
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { ExternalStoreRuntimeCore } from "../runtimes/external-store/external-store-runtime-core";
|
|
3
|
+
import { LocalRuntimeCore } from "../runtimes/local/local-runtime-core";
|
|
4
|
+
import { createMessageQueue } from "../runtime/queue/message-queue";
|
|
5
|
+
import { getThreadMessageText } from "../utils/text";
|
|
6
|
+
import type { ChatModelAdapter } from "../runtime/utils/chat-model-adapter";
|
|
7
|
+
import type { AppendMessage, ThreadMessage } from "../types/message";
|
|
8
|
+
|
|
9
|
+
const live = (state: unknown) => ({
|
|
10
|
+
interactables: [{ id: "n1", name: "note", state }],
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const snapshotMessage = (id: string, state: unknown) =>
|
|
14
|
+
({
|
|
15
|
+
id,
|
|
16
|
+
role: "user",
|
|
17
|
+
createdAt: new Date(),
|
|
18
|
+
content: [{ type: "text", text: "old" }],
|
|
19
|
+
attachments: [],
|
|
20
|
+
metadata: {
|
|
21
|
+
custom: { interactables: [{ id: "n1", name: "note", state }] },
|
|
22
|
+
},
|
|
23
|
+
}) as ThreadMessage;
|
|
24
|
+
|
|
25
|
+
const userMessage = (text: string, parentId: string | null): AppendMessage => ({
|
|
26
|
+
parentId,
|
|
27
|
+
sourceId: null,
|
|
28
|
+
runConfig: {},
|
|
29
|
+
role: "user",
|
|
30
|
+
content: [{ type: "text", text }],
|
|
31
|
+
attachments: [],
|
|
32
|
+
metadata: { custom: {} },
|
|
33
|
+
createdAt: new Date(),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const externalThread = (options?: {
|
|
37
|
+
messages?: ThreadMessage[];
|
|
38
|
+
composerMetadata?: Record<string, unknown>;
|
|
39
|
+
}) => {
|
|
40
|
+
const onNew = vi.fn(async () => {});
|
|
41
|
+
const onEdit = vi.fn(async () => {});
|
|
42
|
+
const core = new ExternalStoreRuntimeCore({
|
|
43
|
+
messages: options?.messages ?? [],
|
|
44
|
+
onNew,
|
|
45
|
+
onEdit,
|
|
46
|
+
});
|
|
47
|
+
if (options?.composerMetadata) {
|
|
48
|
+
core.registerModelContextProvider({
|
|
49
|
+
getModelContext: () => ({
|
|
50
|
+
unstable_composerMetadata: options.composerMetadata,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return { thread: core.threads.getMainThreadRuntimeCore(), onNew, onEdit };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const appendedSnapshots = (sink: ReturnType<typeof vi.fn>) =>
|
|
58
|
+
(sink.mock.calls[0]![0] as AppendMessage).metadata?.custom?.interactables;
|
|
59
|
+
|
|
60
|
+
const tailId = (messages: ThreadMessage[]) => messages.at(-1)?.id ?? null;
|
|
61
|
+
|
|
62
|
+
describe("interactable snapshots on the append seam", () => {
|
|
63
|
+
it("stamps a baseline on the first send of the thread", async () => {
|
|
64
|
+
const { thread, onNew } = externalThread({
|
|
65
|
+
composerMetadata: live({ v: 1 }),
|
|
66
|
+
});
|
|
67
|
+
await thread.append(userMessage("hello", null));
|
|
68
|
+
expect(appendedSnapshots(onNew)).toEqual([
|
|
69
|
+
{ id: "n1", name: "note", state: { v: 1 } },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("stamps nothing when the history already carries the live state", async () => {
|
|
74
|
+
const messages = [snapshotMessage("msg-1", { v: 1 })];
|
|
75
|
+
const { thread, onNew } = externalThread({
|
|
76
|
+
messages,
|
|
77
|
+
composerMetadata: live({ v: 1 }),
|
|
78
|
+
});
|
|
79
|
+
await thread.append(userMessage("hello", tailId(messages)));
|
|
80
|
+
expect(appendedSnapshots(onNew)).toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("stamps the new state when it diverged from the latest snapshot", async () => {
|
|
84
|
+
const messages = [snapshotMessage("msg-1", { v: 1 })];
|
|
85
|
+
const { thread, onNew } = externalThread({
|
|
86
|
+
messages,
|
|
87
|
+
composerMetadata: live({ v: 2 }),
|
|
88
|
+
});
|
|
89
|
+
await thread.append(userMessage("hello", tailId(messages)));
|
|
90
|
+
expect(appendedSnapshots(onNew)).toEqual([
|
|
91
|
+
{ id: "n1", name: "note", state: { v: 2 } },
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("stamps a programmatic append, the path suggestions and thread.append() take", async () => {
|
|
96
|
+
const { thread, onNew } = externalThread({
|
|
97
|
+
composerMetadata: live({ tasks: [] }),
|
|
98
|
+
});
|
|
99
|
+
await thread.append(userMessage("Add 3 tasks for a grocery run", null));
|
|
100
|
+
expect(appendedSnapshots(onNew)).toEqual([
|
|
101
|
+
{ id: "n1", name: "note", state: { tasks: [] } },
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("leaves a non-user append unstamped, since no reader folds those", async () => {
|
|
106
|
+
const { thread, onNew } = externalThread({
|
|
107
|
+
composerMetadata: live({ v: 1 }),
|
|
108
|
+
});
|
|
109
|
+
await thread.append({
|
|
110
|
+
...userMessage("an assistant turn", null),
|
|
111
|
+
role: "assistant",
|
|
112
|
+
} as AppendMessage);
|
|
113
|
+
expect(appendedSnapshots(onNew)).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("re-stamps the baseline when the edited message carried the only snapshot", async () => {
|
|
117
|
+
const messages = [snapshotMessage("msg-1", { v: 1 })];
|
|
118
|
+
const { thread, onEdit } = externalThread({
|
|
119
|
+
messages,
|
|
120
|
+
composerMetadata: live({ v: 1 }),
|
|
121
|
+
});
|
|
122
|
+
// An edit branches off the edited message's parent, so the prefix it is
|
|
123
|
+
// gated against excludes the edited message and its snapshot.
|
|
124
|
+
await thread.append({ ...userMessage("new", null), sourceId: "msg-1" });
|
|
125
|
+
expect(appendedSnapshots(onEdit)).toEqual([
|
|
126
|
+
{ id: "n1", name: "note", state: { v: 1 } },
|
|
127
|
+
]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("stamps nothing when the edit's branch prefix already carries the live state", async () => {
|
|
131
|
+
const messages = [
|
|
132
|
+
snapshotMessage("parent-1", { v: 1 }),
|
|
133
|
+
snapshotMessage("msg-1", { v: 1 }),
|
|
134
|
+
];
|
|
135
|
+
const { thread, onEdit } = externalThread({
|
|
136
|
+
messages,
|
|
137
|
+
composerMetadata: live({ v: 1 }),
|
|
138
|
+
});
|
|
139
|
+
await thread.append({
|
|
140
|
+
...userMessage("new", "parent-1"),
|
|
141
|
+
sourceId: "msg-1",
|
|
142
|
+
});
|
|
143
|
+
expect(appendedSnapshots(onEdit)).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("stamps on the local runtime core too", async () => {
|
|
147
|
+
const run = vi.fn(async function* () {
|
|
148
|
+
yield { content: [{ type: "text" as const, text: "ok" }] };
|
|
149
|
+
} as unknown as ChatModelAdapter["run"]);
|
|
150
|
+
const core = new LocalRuntimeCore(
|
|
151
|
+
{ adapters: { chatModel: { run } } },
|
|
152
|
+
undefined,
|
|
153
|
+
);
|
|
154
|
+
core.registerModelContextProvider({
|
|
155
|
+
getModelContext: () => ({
|
|
156
|
+
unstable_composerMetadata: live({ v: 1 }),
|
|
157
|
+
}),
|
|
158
|
+
});
|
|
159
|
+
const thread = core.threads.getMainThreadRuntimeCore();
|
|
160
|
+
await thread.append(userMessage("hello", null));
|
|
161
|
+
expect(thread.messages[0]!.metadata.custom?.interactables).toEqual([
|
|
162
|
+
{ id: "n1", name: "note", state: { v: 1 } },
|
|
163
|
+
]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("queued sends gate at dispatch, not at enqueue", () => {
|
|
168
|
+
const queuedExternalThread = () => {
|
|
169
|
+
const run = vi.fn();
|
|
170
|
+
const onNew = vi.fn(async () => {});
|
|
171
|
+
const queue = createMessageQueue({ run });
|
|
172
|
+
let composerMetadata: Record<string, unknown> = live({ v: 1 });
|
|
173
|
+
const store = (messages: ThreadMessage[]) => ({
|
|
174
|
+
messages,
|
|
175
|
+
onNew,
|
|
176
|
+
queue: queue.adapter,
|
|
177
|
+
});
|
|
178
|
+
const core = new ExternalStoreRuntimeCore(store([]));
|
|
179
|
+
core.registerModelContextProvider({
|
|
180
|
+
getModelContext: () => ({ unstable_composerMetadata: composerMetadata }),
|
|
181
|
+
});
|
|
182
|
+
return {
|
|
183
|
+
thread: core.threads.getMainThreadRuntimeCore(),
|
|
184
|
+
queue,
|
|
185
|
+
run,
|
|
186
|
+
advanceRecord: (messages: ThreadMessage[], live: unknown) => {
|
|
187
|
+
composerMetadata = live as Record<string, unknown>;
|
|
188
|
+
core.setAdapter(store(messages));
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const dispatched = (run: ReturnType<typeof vi.fn>) =>
|
|
194
|
+
(run.mock.calls[0]![0] as AppendMessage).metadata?.custom?.interactables;
|
|
195
|
+
|
|
196
|
+
it("drops the enqueue-time stamp once the run has told the model that state", async () => {
|
|
197
|
+
const t = queuedExternalThread();
|
|
198
|
+
t.queue.notifyBusy();
|
|
199
|
+
await t.thread.append(userMessage("queued", null));
|
|
200
|
+
expect(t.run).not.toHaveBeenCalled();
|
|
201
|
+
|
|
202
|
+
// the run moved the record to v:2 and the live state matches it
|
|
203
|
+
t.advanceRecord([snapshotMessage("msg-1", { v: 2 })], live({ v: 2 }));
|
|
204
|
+
t.queue.notifyIdle();
|
|
205
|
+
|
|
206
|
+
expect(t.run).toHaveBeenCalledTimes(1);
|
|
207
|
+
expect(dispatched(t.run)).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("stamps the state as of the flush when it still diverges", async () => {
|
|
211
|
+
const t = queuedExternalThread();
|
|
212
|
+
t.queue.notifyBusy();
|
|
213
|
+
await t.thread.append(userMessage("queued", null));
|
|
214
|
+
|
|
215
|
+
// the run moved the record to v:2 while the user edited on to v:3
|
|
216
|
+
t.advanceRecord([snapshotMessage("msg-1", { v: 2 })], live({ v: 3 }));
|
|
217
|
+
t.queue.notifyIdle();
|
|
218
|
+
|
|
219
|
+
expect(dispatched(t.run)).toEqual([
|
|
220
|
+
{ id: "n1", name: "note", state: { v: 3 } },
|
|
221
|
+
]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// interrupt dispatches synchronously inside `append`, so nothing can go
|
|
225
|
+
// stale in between; what matters is that this second dispatch point stamps
|
|
226
|
+
// at all, the way advance does.
|
|
227
|
+
it("stamps on the interrupt path a mid-run steer takes", async () => {
|
|
228
|
+
const run = vi.fn();
|
|
229
|
+
const cancel = vi.fn();
|
|
230
|
+
const onNew = vi.fn(async () => {});
|
|
231
|
+
const queue = createMessageQueue({ run, cancel });
|
|
232
|
+
let composerMetadata: Record<string, unknown> = live({ v: 1 });
|
|
233
|
+
const store = (messages: ThreadMessage[]) => ({
|
|
234
|
+
messages,
|
|
235
|
+
onNew,
|
|
236
|
+
queue: queue.adapter,
|
|
237
|
+
isRunning: true,
|
|
238
|
+
});
|
|
239
|
+
const core = new ExternalStoreRuntimeCore(store([]));
|
|
240
|
+
core.registerModelContextProvider({
|
|
241
|
+
getModelContext: () => ({ unstable_composerMetadata: composerMetadata }),
|
|
242
|
+
});
|
|
243
|
+
const thread = core.threads.getMainThreadRuntimeCore();
|
|
244
|
+
|
|
245
|
+
queue.notifyBusy();
|
|
246
|
+
composerMetadata = live({ v: 2 });
|
|
247
|
+
// isRunning routes this to the steer lane, and a cancel-capable driver
|
|
248
|
+
// makes that an interrupt rather than a buffered enqueue
|
|
249
|
+
await thread.append(
|
|
250
|
+
userMessage("steered", thread.messages.at(-1)?.id ?? null),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
expect(cancel).toHaveBeenCalled();
|
|
254
|
+
expect(dispatched(run)).toEqual([
|
|
255
|
+
{ id: "n1", name: "note", state: { v: 2 } },
|
|
256
|
+
]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("re-points a flushed message at the tail it was gated against", async () => {
|
|
260
|
+
const t = queuedExternalThread();
|
|
261
|
+
t.queue.notifyBusy();
|
|
262
|
+
await t.thread.append(userMessage("queued", null));
|
|
263
|
+
|
|
264
|
+
t.advanceRecord([snapshotMessage("msg-1", { v: 2 })], live({ v: 3 }));
|
|
265
|
+
t.queue.notifyIdle();
|
|
266
|
+
|
|
267
|
+
// routing and gating have to name the same point, or the branch the
|
|
268
|
+
// message lands on is not the one its snapshot was computed against
|
|
269
|
+
expect((t.run.mock.calls[0]![0] as AppendMessage).parentId).toBe("msg-1");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("leaves a hand-rolled adapter stamping at enqueue", async () => {
|
|
273
|
+
const enqueue = vi.fn();
|
|
274
|
+
const onNew = vi.fn(async () => {});
|
|
275
|
+
const core = new ExternalStoreRuntimeCore({
|
|
276
|
+
messages: [],
|
|
277
|
+
onNew,
|
|
278
|
+
// no __internal_setDispatchTransform: nothing re-gates on the way out,
|
|
279
|
+
// so the runtime has to stamp before handing the message over
|
|
280
|
+
queue: {
|
|
281
|
+
items: [],
|
|
282
|
+
steerItems: [],
|
|
283
|
+
enqueue,
|
|
284
|
+
steer: vi.fn(),
|
|
285
|
+
move: vi.fn(),
|
|
286
|
+
edit: vi.fn(),
|
|
287
|
+
remove: vi.fn(),
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
core.registerModelContextProvider({
|
|
291
|
+
getModelContext: () => ({ unstable_composerMetadata: live({ v: 1 }) }),
|
|
292
|
+
});
|
|
293
|
+
const thread = core.threads.getMainThreadRuntimeCore();
|
|
294
|
+
|
|
295
|
+
await thread.append(userMessage("queued", null));
|
|
296
|
+
expect(dispatched(enqueue)).toEqual([
|
|
297
|
+
{ id: "n1", name: "note", state: { v: 1 } },
|
|
298
|
+
]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("gates a queued local send against the tail it flushes onto", async () => {
|
|
302
|
+
let composerMetadata: Record<string, unknown> = live({ v: 1 });
|
|
303
|
+
let releaseFirstRun!: () => void;
|
|
304
|
+
const firstRunGate = new Promise<void>((resolve) => {
|
|
305
|
+
releaseFirstRun = resolve;
|
|
306
|
+
});
|
|
307
|
+
let runs = 0;
|
|
308
|
+
const chatModel: ChatModelAdapter = {
|
|
309
|
+
run: async function* () {
|
|
310
|
+
if (runs++ === 0) await firstRunGate;
|
|
311
|
+
yield { content: [{ type: "text" as const, text: "ok" }] };
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
const core = new LocalRuntimeCore(
|
|
315
|
+
{ adapters: { chatModel }, unstable_enableMessageQueue: true },
|
|
316
|
+
undefined,
|
|
317
|
+
);
|
|
318
|
+
core.registerModelContextProvider({
|
|
319
|
+
getModelContext: () => ({ unstable_composerMetadata: composerMetadata }),
|
|
320
|
+
});
|
|
321
|
+
const thread = core.threads.getMainThreadRuntimeCore();
|
|
322
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
|
323
|
+
|
|
324
|
+
void thread.append(userMessage("first", null));
|
|
325
|
+
await settle();
|
|
326
|
+
|
|
327
|
+
// the first run is still open, so this one buffers with the live state at v:1
|
|
328
|
+
void thread.append(
|
|
329
|
+
userMessage("second", thread.messages.at(-1)?.id ?? null),
|
|
330
|
+
);
|
|
331
|
+
await settle();
|
|
332
|
+
expect(
|
|
333
|
+
thread.messages.some(
|
|
334
|
+
(m) => m.role === "user" && getThreadMessageText(m) === "second",
|
|
335
|
+
),
|
|
336
|
+
).toBe(false);
|
|
337
|
+
|
|
338
|
+
composerMetadata = live({ v: 2 });
|
|
339
|
+
releaseFirstRun();
|
|
340
|
+
await settle();
|
|
341
|
+
await settle();
|
|
342
|
+
|
|
343
|
+
const second = thread.messages.find(
|
|
344
|
+
(m) => m.role === "user" && getThreadMessageText(m) === "second",
|
|
345
|
+
);
|
|
346
|
+
expect(second!.metadata.custom?.interactables).toEqual([
|
|
347
|
+
{ id: "n1", name: "note", state: { v: 2 } },
|
|
348
|
+
]);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -3,6 +3,8 @@ import { DefaultThreadComposerRuntimeCore } from "../runtime/base/default-thread
|
|
|
3
3
|
import type { AttachmentAdapter } from "../adapters/attachment";
|
|
4
4
|
import type { ThreadRuntimeCore } from "../runtime/interfaces/thread-runtime-core";
|
|
5
5
|
import type { PendingAttachment } from "../types/attachment";
|
|
6
|
+
import { MessageNotSentError } from "../types/error";
|
|
7
|
+
import { BaseComposerRuntimeCore } from "../runtime/base/base-composer-runtime-core";
|
|
6
8
|
|
|
7
9
|
const makeAdapter = (
|
|
8
10
|
overrides: Partial<AttachmentAdapter> = {},
|
|
@@ -571,3 +573,127 @@ describe("BaseComposerRuntimeCore send event listener isolation", () => {
|
|
|
571
573
|
});
|
|
572
574
|
});
|
|
573
575
|
});
|
|
576
|
+
|
|
577
|
+
describe("BaseComposerRuntimeCore.send restore-on-undispatched", () => {
|
|
578
|
+
const rejectableAppend = () => {
|
|
579
|
+
let reject!: (error: unknown) => void;
|
|
580
|
+
const append = vi.fn(
|
|
581
|
+
() =>
|
|
582
|
+
new Promise<void>((_resolve, rejectAppend) => {
|
|
583
|
+
reject = rejectAppend;
|
|
584
|
+
}),
|
|
585
|
+
);
|
|
586
|
+
return { append, reject: (error: unknown) => reject(error) };
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
it("restores text, quote, and attachments when the message was never sent", async () => {
|
|
590
|
+
const { append, reject } = rejectableAppend();
|
|
591
|
+
const { composer } = makeComposer(makeAdapter(), append);
|
|
592
|
+
|
|
593
|
+
composer.setText("hello");
|
|
594
|
+
await composer.addAttachment(textFile());
|
|
595
|
+
composer.setQuote({ text: "quoted", messageId: "m-1" });
|
|
596
|
+
|
|
597
|
+
await composer.send();
|
|
598
|
+
expect(composer.text).toBe("");
|
|
599
|
+
expect(composer.attachments).toHaveLength(0);
|
|
600
|
+
|
|
601
|
+
reject(new MessageNotSentError());
|
|
602
|
+
|
|
603
|
+
await vi.waitFor(() => expect(composer.text).toBe("hello"));
|
|
604
|
+
expect(composer.quote).toEqual({ text: "quoted", messageId: "m-1" });
|
|
605
|
+
expect(composer.attachments).toHaveLength(1);
|
|
606
|
+
expect(composer.attachments[0]!.status).toEqual({ type: "complete" });
|
|
607
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("leaves the composer empty for any other rejection", async () => {
|
|
611
|
+
const { append, reject } = rejectableAppend();
|
|
612
|
+
const { composer } = makeComposer(makeAdapter(), append);
|
|
613
|
+
|
|
614
|
+
composer.setText("hello");
|
|
615
|
+
await composer.send();
|
|
616
|
+
reject(new Error("boom"));
|
|
617
|
+
|
|
618
|
+
await vi.waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
619
|
+
expect(composer.text).toBe("");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("does not clobber a draft written after the send", async () => {
|
|
623
|
+
const { append, reject } = rejectableAppend();
|
|
624
|
+
const { composer } = makeComposer(makeAdapter(), append);
|
|
625
|
+
|
|
626
|
+
composer.setText("hello");
|
|
627
|
+
await composer.send();
|
|
628
|
+
composer.setText("new draft");
|
|
629
|
+
reject(new MessageNotSentError());
|
|
630
|
+
|
|
631
|
+
await vi.waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
632
|
+
expect(composer.text).toBe("new draft");
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it("does not restore a draft a reset discarded", async () => {
|
|
636
|
+
const { append, reject } = rejectableAppend();
|
|
637
|
+
const { composer } = makeComposer(makeAdapter(), append);
|
|
638
|
+
|
|
639
|
+
composer.setText("hello");
|
|
640
|
+
await composer.send();
|
|
641
|
+
await composer.reset();
|
|
642
|
+
reject(new MessageNotSentError());
|
|
643
|
+
|
|
644
|
+
await vi.waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
645
|
+
expect(composer.text).toBe("");
|
|
646
|
+
});
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
describe("BaseComposerRuntimeCore.send restore-on-undispatched edge cases", () => {
|
|
650
|
+
class SyncThrowComposerCore extends BaseComposerRuntimeCore {
|
|
651
|
+
public readonly error = new MessageNotSentError();
|
|
652
|
+
public get canCancel() {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
public get canSend() {
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
protected getAttachmentAdapter() {
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
protected getDictationAdapter() {
|
|
662
|
+
return undefined;
|
|
663
|
+
}
|
|
664
|
+
protected handleSend(): void {
|
|
665
|
+
throw this.error;
|
|
666
|
+
}
|
|
667
|
+
protected handleCancel(): void {}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
it("restores the draft when handleSend throws synchronously", async () => {
|
|
671
|
+
const composer = new SyncThrowComposerCore();
|
|
672
|
+
composer.setText("hello");
|
|
673
|
+
|
|
674
|
+
await expect(composer.send()).rejects.toThrow(composer.error);
|
|
675
|
+
|
|
676
|
+
expect(composer.text).toBe("hello");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("restores only the most recent of several queued drafts", async () => {
|
|
680
|
+
const rejects: ((error: unknown) => void)[] = [];
|
|
681
|
+
const append = vi.fn(
|
|
682
|
+
() =>
|
|
683
|
+
new Promise<void>((_resolve, reject) => {
|
|
684
|
+
rejects.push(reject);
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
const { composer } = makeComposer(makeAdapter(), append);
|
|
688
|
+
|
|
689
|
+
composer.setText("one");
|
|
690
|
+
await composer.send();
|
|
691
|
+
composer.setText("two");
|
|
692
|
+
await composer.send();
|
|
693
|
+
|
|
694
|
+
rejects[0]!(new MessageNotSentError());
|
|
695
|
+
rejects[1]!(new MessageNotSentError());
|
|
696
|
+
|
|
697
|
+
await vi.waitFor(() => expect(composer.text).toBe("two"));
|
|
698
|
+
});
|
|
699
|
+
});
|
|
@@ -14,8 +14,14 @@ class TestComposerCore extends BaseComposerRuntimeCore {
|
|
|
14
14
|
protected getAttachmentAdapter() {
|
|
15
15
|
return this._attachmentAdapter;
|
|
16
16
|
}
|
|
17
|
+
private _dictationAdapter: DictationAdapter | undefined;
|
|
18
|
+
|
|
17
19
|
protected getDictationAdapter(): DictationAdapter | undefined {
|
|
18
|
-
return
|
|
20
|
+
return this._dictationAdapter;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setDictationAdapter(adapter: DictationAdapter | undefined) {
|
|
24
|
+
this._dictationAdapter = adapter;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
setAttachmentAdapter(adapter: AttachmentAdapter | undefined) {
|
|
@@ -420,3 +426,104 @@ describe("BaseComposerRuntimeCore", () => {
|
|
|
420
426
|
});
|
|
421
427
|
});
|
|
422
428
|
});
|
|
429
|
+
|
|
430
|
+
describe("BaseComposerRuntimeCore.restoreDraft", () => {
|
|
431
|
+
it("refuses while the composer holds something of its own", () => {
|
|
432
|
+
const composer = new TestComposerCore();
|
|
433
|
+
composer.setText("mine");
|
|
434
|
+
|
|
435
|
+
expect(composer.restoreDraft({ text: "returned" })).toBe(false);
|
|
436
|
+
expect(composer.text).toBe("mine");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("rebases a live dictation session onto the restored text", () => {
|
|
440
|
+
let emitSpeech!: (result: { transcript: string }) => void;
|
|
441
|
+
const composer = new TestComposerCore();
|
|
442
|
+
composer.setDictationAdapter({
|
|
443
|
+
listen: () => ({
|
|
444
|
+
status: { type: "running" },
|
|
445
|
+
stop: async () => {},
|
|
446
|
+
cancel: () => {},
|
|
447
|
+
onSpeechStart: () => () => {},
|
|
448
|
+
onSpeechEnd: () => () => {},
|
|
449
|
+
onSpeech: (callback) => {
|
|
450
|
+
emitSpeech = callback;
|
|
451
|
+
return () => {};
|
|
452
|
+
},
|
|
453
|
+
}),
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
composer.startDictation();
|
|
457
|
+
expect(composer.restoreDraft({ text: "returned" })).toBe(true);
|
|
458
|
+
emitSpeech({ transcript: "and this" });
|
|
459
|
+
|
|
460
|
+
expect(composer.text).toBe("returned and this");
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("BaseComposerRuntimeCore.retractDraft", () => {
|
|
465
|
+
const makeCompleteAttachment = (id: string) => ({
|
|
466
|
+
id,
|
|
467
|
+
type: "file" as const,
|
|
468
|
+
name: "spec.pdf",
|
|
469
|
+
contentType: "application/pdf",
|
|
470
|
+
status: { type: "complete" as const },
|
|
471
|
+
content: [],
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("clears the composer while it still holds the exact draft", () => {
|
|
475
|
+
const composer = new TestComposerCore();
|
|
476
|
+
const draft = {
|
|
477
|
+
text: "returned",
|
|
478
|
+
quote: { text: "quoted", messageId: "m-1" },
|
|
479
|
+
attachments: [makeCompleteAttachment("a1")],
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
expect(composer.restoreDraft(draft)).toBe(true);
|
|
483
|
+
composer.retractDraft(draft);
|
|
484
|
+
|
|
485
|
+
expect(composer.text).toBe("");
|
|
486
|
+
expect(composer.quote).toBeUndefined();
|
|
487
|
+
expect(composer.attachments).toEqual([]);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("leaves an edited text alone", () => {
|
|
491
|
+
const composer = new TestComposerCore();
|
|
492
|
+
const draft = { text: "returned" };
|
|
493
|
+
|
|
494
|
+
expect(composer.restoreDraft(draft)).toBe(true);
|
|
495
|
+
composer.setText("edited");
|
|
496
|
+
composer.retractDraft(draft);
|
|
497
|
+
|
|
498
|
+
expect(composer.text).toBe("edited");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("leaves a changed quote alone", () => {
|
|
502
|
+
const composer = new TestComposerCore();
|
|
503
|
+
const draft = { text: "returned", quote: { text: "quoted" } };
|
|
504
|
+
|
|
505
|
+
expect(composer.restoreDraft(draft)).toBe(true);
|
|
506
|
+
composer.setQuote({ text: "other" });
|
|
507
|
+
composer.retractDraft(draft);
|
|
508
|
+
|
|
509
|
+
expect(composer.text).toBe("returned");
|
|
510
|
+
expect(composer.quote).toEqual({ text: "other" });
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("leaves changed attachments alone", () => {
|
|
514
|
+
const composer = new TestComposerCore();
|
|
515
|
+
const draft = {
|
|
516
|
+
text: "returned",
|
|
517
|
+
attachments: [makeCompleteAttachment("a1")],
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
expect(composer.restoreDraft(draft)).toBe(true);
|
|
521
|
+
composer.retractDraft({
|
|
522
|
+
...draft,
|
|
523
|
+
attachments: [makeCompleteAttachment("a1")],
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
expect(composer.text).toBe("returned");
|
|
527
|
+
expect(composer.attachments).toHaveLength(1);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
@@ -229,74 +229,6 @@ describe("DefaultEditComposerRuntimeCore", () => {
|
|
|
229
229
|
});
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
-
describe("interactables snapshot gating", () => {
|
|
233
|
-
const live = (state: unknown) => ({
|
|
234
|
-
interactables: [{ id: "n1", name: "note", state }],
|
|
235
|
-
});
|
|
236
|
-
const snapshotMessage = (id: string, state: unknown): ThreadMessage =>
|
|
237
|
-
makeUserMessage({
|
|
238
|
-
id,
|
|
239
|
-
metadata: {
|
|
240
|
-
custom: { interactables: [{ id: "n1", name: "note", state }] },
|
|
241
|
-
},
|
|
242
|
-
} as Partial<ThreadMessage>);
|
|
243
|
-
|
|
244
|
-
it("re-stamps the baseline when the edited message carried the only snapshot", async () => {
|
|
245
|
-
// The new branch's prefix (before the edited message) has no snapshot,
|
|
246
|
-
// so the live (unchanged) state becomes the branch's baseline.
|
|
247
|
-
const edited = snapshotMessage("msg-1", { v: 1 });
|
|
248
|
-
const { runtime, append } = makeRuntime({
|
|
249
|
-
messages: [edited],
|
|
250
|
-
composerMetadata: live({ v: 1 }),
|
|
251
|
-
});
|
|
252
|
-
const composer = new DefaultEditComposerRuntimeCore(runtime, () => {}, {
|
|
253
|
-
parentId: null,
|
|
254
|
-
message: edited,
|
|
255
|
-
});
|
|
256
|
-
composer.setText("new");
|
|
257
|
-
await composer.send();
|
|
258
|
-
const appended = append.mock.calls[0]![0] as AppendMessage;
|
|
259
|
-
expect(appended.metadata?.custom?.interactables).toEqual([
|
|
260
|
-
{ id: "n1", name: "note", state: { v: 1 } },
|
|
261
|
-
]);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("stamps the newest live state when the interactable was edited after the original message", async () => {
|
|
265
|
-
const edited = snapshotMessage("msg-1", { v: 1 });
|
|
266
|
-
const { runtime, append } = makeRuntime({
|
|
267
|
-
messages: [edited],
|
|
268
|
-
composerMetadata: live({ v: 2 }),
|
|
269
|
-
});
|
|
270
|
-
const composer = new DefaultEditComposerRuntimeCore(runtime, () => {}, {
|
|
271
|
-
parentId: null,
|
|
272
|
-
message: edited,
|
|
273
|
-
});
|
|
274
|
-
composer.setText("new");
|
|
275
|
-
await composer.send();
|
|
276
|
-
const appended = append.mock.calls[0]![0] as AppendMessage;
|
|
277
|
-
expect(appended.metadata?.custom?.interactables).toEqual([
|
|
278
|
-
{ id: "n1", name: "note", state: { v: 2 } },
|
|
279
|
-
]);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
it("stamps nothing when the branch prefix already carries the live state", async () => {
|
|
283
|
-
const parent = snapshotMessage("parent-1", { v: 1 });
|
|
284
|
-
const edited = makeUserMessage({ id: "msg-1" });
|
|
285
|
-
const { runtime, append } = makeRuntime({
|
|
286
|
-
messages: [parent, edited],
|
|
287
|
-
composerMetadata: live({ v: 1 }),
|
|
288
|
-
});
|
|
289
|
-
const composer = new DefaultEditComposerRuntimeCore(runtime, () => {}, {
|
|
290
|
-
parentId: "parent-1",
|
|
291
|
-
message: edited,
|
|
292
|
-
});
|
|
293
|
-
composer.setText("new");
|
|
294
|
-
await composer.send();
|
|
295
|
-
const appended = append.mock.calls[0]![0] as AppendMessage;
|
|
296
|
-
expect(appended.metadata?.custom?.interactables).toBeUndefined();
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
232
|
describe("non-user messages", () => {
|
|
301
233
|
it("does not lift non-text parts for assistant messages", () => {
|
|
302
234
|
const { runtime } = makeRuntime();
|