@assistant-ui/core 0.3.5 → 0.3.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/dist/index.d.ts +3 -3
  2. package/dist/index.js.map +1 -1
  3. package/dist/internal.js +1 -1
  4. package/dist/react/AssistantProvider.d.ts +3 -2
  5. package/dist/react/AssistantProvider.d.ts.map +1 -1
  6. package/dist/react/AssistantProvider.js +19 -26
  7. package/dist/react/AssistantProvider.js.map +1 -1
  8. package/dist/react/AssistantRuntimeProvider.d.ts +7 -2
  9. package/dist/react/AssistantRuntimeProvider.d.ts.map +1 -1
  10. package/dist/react/AssistantRuntimeProvider.js +9 -7
  11. package/dist/react/AssistantRuntimeProvider.js.map +1 -1
  12. package/dist/react/providers/AttachmentByIndexProvider.d.ts.map +1 -1
  13. package/dist/react/providers/AttachmentByIndexProvider.js +33 -39
  14. package/dist/react/providers/AttachmentByIndexProvider.js.map +1 -1
  15. package/dist/react/providers/ChainOfThoughtByIndicesProvider.d.ts.map +1 -1
  16. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js +15 -20
  17. package/dist/react/providers/ChainOfThoughtByIndicesProvider.js.map +1 -1
  18. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js +17 -20
  19. package/dist/react/providers/ChainOfThoughtPartByIndexProvider.js.map +1 -1
  20. package/dist/react/providers/MessageByIdProvider.js +28 -38
  21. package/dist/react/providers/MessageByIdProvider.js.map +1 -1
  22. package/dist/react/providers/MessageByIndexProvider.js +28 -38
  23. package/dist/react/providers/MessageByIndexProvider.js.map +1 -1
  24. package/dist/react/providers/PartByIndexProvider.js +17 -20
  25. package/dist/react/providers/PartByIndexProvider.js.map +1 -1
  26. package/dist/react/providers/QueueItemByIndexProvider.js +21 -21
  27. package/dist/react/providers/QueueItemByIndexProvider.js.map +1 -1
  28. package/dist/react/providers/ReadonlyThreadProvider.js +24 -32
  29. package/dist/react/providers/ReadonlyThreadProvider.js.map +1 -1
  30. package/dist/react/providers/SuggestionByIndexProvider.js +17 -20
  31. package/dist/react/providers/SuggestionByIndexProvider.js.map +1 -1
  32. package/dist/react/providers/TextMessagePartProvider.d.ts.map +1 -1
  33. package/dist/react/providers/TextMessagePartProvider.js +16 -19
  34. package/dist/react/providers/TextMessagePartProvider.js.map +1 -1
  35. package/dist/react/providers/ThreadListItemByIndexProvider.js +17 -20
  36. package/dist/react/providers/ThreadListItemByIndexProvider.js.map +1 -1
  37. package/dist/react/providers/ThreadListItemRuntimeProvider.js +15 -18
  38. package/dist/react/providers/ThreadListItemRuntimeProvider.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +7 -3
  40. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  41. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +5 -2
  42. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  43. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +3 -2
  44. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  45. package/dist/react/runtimes/useLocalRuntime.d.ts +3 -1
  46. package/dist/react/runtimes/useLocalRuntime.d.ts.map +1 -1
  47. package/dist/react/runtimes/useLocalRuntime.js +4 -2
  48. package/dist/react/runtimes/useLocalRuntime.js.map +1 -1
  49. package/dist/runtime/api/composer-runtime.d.ts +5 -1
  50. package/dist/runtime/api/composer-runtime.d.ts.map +1 -1
  51. package/dist/runtime/api/composer-runtime.js +8 -1
  52. package/dist/runtime/api/composer-runtime.js.map +1 -1
  53. package/dist/runtime/api/message-runtime.js +1 -1
  54. package/dist/runtime/api/message-runtime.js.map +1 -1
  55. package/dist/runtime/api/thread-runtime.d.ts +4 -2
  56. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  57. package/dist/runtime/base/base-composer-runtime-core.d.ts +2 -1
  58. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  59. package/dist/runtime/base/base-composer-runtime-core.js +1 -1
  60. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  61. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts +3 -1
  62. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  63. package/dist/runtime/base/default-thread-composer-runtime-core.js +14 -3
  64. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  65. package/dist/runtime/interfaces/composer-runtime-core.d.ts +2 -1
  66. package/dist/runtime/interfaces/composer-runtime-core.d.ts.map +1 -1
  67. package/dist/runtime/interfaces/thread-runtime-core.d.ts +3 -1
  68. package/dist/runtime/interfaces/thread-runtime-core.d.ts.map +1 -1
  69. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +19 -6
  70. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  71. package/dist/runtime/queue/message-queue.d.ts +10 -1
  72. package/dist/runtime/queue/message-queue.d.ts.map +1 -1
  73. package/dist/runtime/queue/message-queue.js +138 -36
  74. package/dist/runtime/queue/message-queue.js.map +1 -1
  75. package/dist/runtime/utils/thread-message-like.d.ts.map +1 -1
  76. package/dist/runtime/utils/thread-message-like.js +2 -1
  77. package/dist/runtime/utils/thread-message-like.js.map +1 -1
  78. package/dist/runtimes/external-store/external-store-adapter.d.ts +1 -1
  79. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  80. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  81. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +7 -6
  82. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  83. package/dist/runtimes/local/local-runtime-options.d.ts +13 -0
  84. package/dist/runtimes/local/local-runtime-options.d.ts.map +1 -1
  85. package/dist/runtimes/local/local-thread-runtime-core.d.ts +5 -1
  86. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  87. package/dist/runtimes/local/local-thread-runtime-core.js +30 -14
  88. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  89. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +1 -1
  90. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +1 -1
  91. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  92. package/dist/runtimes/remote-thread-list/empty-thread-core.js +1 -1
  93. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  94. package/dist/runtimes/remote-thread-list/types.d.ts +6 -3
  95. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  96. package/dist/store/runtime-clients/composer-runtime-client.d.ts.map +1 -1
  97. package/dist/store/runtime-clients/composer-runtime-client.js +11 -4
  98. package/dist/store/runtime-clients/composer-runtime-client.js.map +1 -1
  99. package/dist/store/scopes/composer.d.ts +7 -3
  100. package/dist/store/scopes/composer.d.ts.map +1 -1
  101. package/dist/store/scopes/queue-item.d.ts +10 -0
  102. package/dist/store/scopes/queue-item.d.ts.map +1 -1
  103. package/dist/store/scopes/queue-item.js.map +1 -1
  104. package/package.json +5 -5
  105. package/src/index.ts +5 -1
  106. package/src/react/AssistantProvider.tsx +20 -18
  107. package/src/react/AssistantRuntimeProvider.test.tsx +55 -1
  108. package/src/react/AssistantRuntimeProvider.tsx +12 -2
  109. package/src/react/providers/AttachmentByIndexProvider.tsx +15 -7
  110. package/src/react/providers/ChainOfThoughtByIndicesProvider.tsx +12 -4
  111. package/src/react/providers/ChainOfThoughtPartByIndexProvider.tsx +8 -4
  112. package/src/react/providers/MessageByIdProvider.tsx +8 -4
  113. package/src/react/providers/MessageByIndexProvider.tsx +8 -4
  114. package/src/react/providers/PartByIndexProvider.tsx +8 -4
  115. package/src/react/providers/QueueItemByIndexProvider.tsx +9 -5
  116. package/src/react/providers/ReadonlyThreadProvider.tsx +9 -4
  117. package/src/react/providers/SuggestionByIndexProvider.tsx +8 -4
  118. package/src/react/providers/TextMessagePartProvider.tsx +13 -4
  119. package/src/react/providers/ThreadListItemByIndexProvider.tsx +8 -4
  120. package/src/react/providers/ThreadListItemRuntimeProvider.tsx +8 -4
  121. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +19 -4
  122. package/src/react/runtimes/useLocalRuntime.ts +4 -0
  123. package/src/runtime/api/composer-runtime.ts +11 -2
  124. package/src/runtime/api/message-runtime.test.ts +25 -0
  125. package/src/runtime/api/message-runtime.ts +1 -1
  126. package/src/runtime/base/base-composer-runtime-core.ts +5 -1
  127. package/src/runtime/base/default-thread-composer-runtime-core.ts +27 -3
  128. package/src/runtime/interfaces/composer-runtime-core.ts +2 -1
  129. package/src/runtime/interfaces/thread-runtime-core.ts +3 -1
  130. package/src/runtime/queue/external-thread-queue-adapter.ts +19 -3
  131. package/src/runtime/queue/message-queue.ts +200 -38
  132. package/src/runtime/utils/thread-message-like.ts +2 -4
  133. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +10 -8
  134. package/src/runtimes/local/local-runtime-options.ts +15 -0
  135. package/src/runtimes/local/local-thread-runtime-core.test.ts +43 -0
  136. package/src/runtimes/local/local-thread-runtime-core.ts +48 -13
  137. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +1 -1
  138. package/src/runtimes/remote-thread-list/empty-thread-core.ts +1 -1
  139. package/src/runtimes/remote-thread-list/types.ts +9 -2
  140. package/src/store/runtime-clients/composer-runtime-client.ts +13 -5
  141. package/src/store/scopes/composer.ts +6 -4
  142. package/src/store/scopes/queue-item.ts +8 -1
  143. package/src/tests/external-store-thread-runtime-core.test.ts +56 -13
  144. package/src/tests/framework-neutral-boundary.test.ts +221 -0
  145. package/src/tests/message-queue.test.ts +494 -34
  146. package/src/tests/thread-message-like.test.ts +66 -0
@@ -14,18 +14,21 @@ const msg = (text: string, extra?: Partial<AppendMessage>): AppendMessage => ({
14
14
  ...extra,
15
15
  });
16
16
 
17
+ const prompts = (items: readonly { prompt: string }[]) =>
18
+ items.map((i) => i.prompt);
19
+
17
20
  describe("createMessageQueue", () => {
18
21
  it("runs immediately when idle and holds while running", () => {
19
22
  const run = vi.fn();
20
23
  const { adapter, notifyIdle } = createMessageQueue({ run });
21
24
 
22
- adapter.enqueue(msg("first"), { steer: false });
25
+ adapter.enqueue(msg("first"));
23
26
  expect(run).toHaveBeenCalledTimes(1);
24
27
  expect(adapter.items).toHaveLength(0);
25
28
 
26
- adapter.enqueue(msg("second"), { steer: false });
29
+ adapter.enqueue(msg("second"));
27
30
  expect(run).toHaveBeenCalledTimes(1);
28
- expect(adapter.items.map((i) => i.prompt)).toEqual(["second"]);
31
+ expect(prompts(adapter.items)).toEqual(["second"]);
29
32
 
30
33
  notifyIdle();
31
34
  expect(run).toHaveBeenCalledTimes(2);
@@ -39,10 +42,10 @@ describe("createMessageQueue", () => {
39
42
  );
40
43
  const { adapter, notifyIdle } = createMessageQueue({ run });
41
44
 
42
- adapter.enqueue(msg("a"), { steer: false }); // runs now
43
- adapter.enqueue(msg("b"), { steer: false });
44
- adapter.enqueue(msg("c"), { steer: false });
45
- expect(adapter.items.map((i) => i.prompt)).toEqual(["b", "c"]);
45
+ adapter.enqueue(msg("a")); // runs now
46
+ adapter.enqueue(msg("b"));
47
+ adapter.enqueue(msg("c"));
48
+ expect(prompts(adapter.items)).toEqual(["b", "c"]);
46
49
 
47
50
  notifyIdle();
48
51
  notifyIdle();
@@ -55,10 +58,8 @@ describe("createMessageQueue", () => {
55
58
  const attachments = [{ id: "x" }] as never;
56
59
  const runConfig = { custom: { k: 1 } };
57
60
 
58
- adapter.enqueue(msg("busy"), { steer: false });
59
- adapter.enqueue(msg("queued", { attachments, runConfig }), {
60
- steer: false,
61
- });
61
+ adapter.enqueue(msg("busy"));
62
+ adapter.enqueue(msg("queued", { attachments, runConfig }));
62
63
  notifyIdle();
63
64
 
64
65
  expect(run).toHaveBeenLastCalledWith(
@@ -67,16 +68,101 @@ describe("createMessageQueue", () => {
67
68
  );
68
69
  });
69
70
 
71
+ it("projects text parts onto queue items", () => {
72
+ const run = vi.fn();
73
+ const { adapter, notifyBusy } = createMessageQueue({ run });
74
+ notifyBusy();
75
+
76
+ adapter.enqueue(msg("hello"));
77
+ expect(adapter.items[0]!.parts).toEqual([{ type: "text", text: "hello" }]);
78
+ expect(adapter.items[0]!.prompt).toBe("hello");
79
+ });
80
+
81
+ it("excludes attachment-derived text parts from the projection", () => {
82
+ const run = vi.fn();
83
+ const { adapter, notifyBusy } = createMessageQueue({ run });
84
+ notifyBusy();
85
+
86
+ adapter.enqueue(
87
+ msg("caption", {
88
+ attachments: [
89
+ {
90
+ id: "att1",
91
+ type: "document",
92
+ name: "notes.md",
93
+ contentType: "text/markdown",
94
+ status: { type: "complete" },
95
+ content: [
96
+ {
97
+ type: "text",
98
+ text: "<attachment>entire file body</attachment>",
99
+ },
100
+ ],
101
+ },
102
+ ],
103
+ }),
104
+ );
105
+
106
+ expect(adapter.items[0]!.parts).toEqual([
107
+ { type: "text", text: "caption" },
108
+ ]);
109
+ });
110
+
111
+ it("projects parts in source order, converting image parts to file parts", () => {
112
+ const run = vi.fn();
113
+ const { adapter, notifyBusy } = createMessageQueue({ run });
114
+ notifyBusy();
115
+
116
+ adapter.enqueue(
117
+ msg("caption", {
118
+ content: [
119
+ { type: "text", text: "caption" },
120
+ { type: "image", image: "https://example.com/cat.png" },
121
+ ],
122
+ attachments: [
123
+ {
124
+ id: "att1",
125
+ type: "file",
126
+ name: "doc.pdf",
127
+ contentType: "application/pdf",
128
+ status: { type: "complete" },
129
+ content: [
130
+ {
131
+ type: "file",
132
+ data: "data:application/pdf;base64,QQ==",
133
+ mimeType: "application/pdf",
134
+ },
135
+ ],
136
+ },
137
+ ],
138
+ }),
139
+ );
140
+
141
+ expect(adapter.items[0]!.parts).toEqual([
142
+ { type: "text", text: "caption" },
143
+ {
144
+ type: "file",
145
+ data: "https://example.com/cat.png",
146
+ mimeType: "image/*",
147
+ },
148
+ {
149
+ type: "file",
150
+ data: "data:application/pdf;base64,QQ==",
151
+ mimeType: "application/pdf",
152
+ },
153
+ ]);
154
+ });
155
+
70
156
  it("removes a queued message before it runs", () => {
71
157
  const run = vi.fn();
72
158
  const { adapter, notifyIdle } = createMessageQueue({ run });
73
159
 
74
- adapter.enqueue(msg("a"), { steer: false }); // runs now
75
- adapter.enqueue(msg("b"), { steer: false });
76
- adapter.enqueue(msg("c"), { steer: false });
160
+ adapter.enqueue(msg("a")); // runs now
161
+ adapter.enqueue(msg("b"));
162
+ adapter.enqueue(msg("c"));
77
163
 
78
164
  adapter.remove(adapter.items[0]!.id); // remove "b"
79
- expect(adapter.items.map((i) => i.prompt)).toEqual(["c"]);
165
+ expect(prompts(adapter.items)).toEqual(["c"]);
80
166
 
81
167
  notifyIdle();
82
168
  expect(run).toHaveBeenLastCalledWith(
@@ -85,23 +171,35 @@ describe("createMessageQueue", () => {
85
171
  );
86
172
  });
87
173
 
88
- it("clear() empties pending items", () => {
174
+ it("edits a queued message in place, keeping id and position", () => {
89
175
  const run = vi.fn();
90
- const { adapter } = createMessageQueue({ run });
91
- adapter.enqueue(msg("a"), { steer: false });
92
- adapter.enqueue(msg("b"), { steer: false });
93
- adapter.clear("cancel-run");
94
- expect(adapter.items).toHaveLength(0);
176
+ const { adapter, notifyBusy } = createMessageQueue({ run });
177
+ notifyBusy();
178
+
179
+ adapter.enqueue(msg("a"));
180
+ adapter.enqueue(msg("b"));
181
+ const id = adapter.items[0]!.id;
182
+
183
+ adapter.edit(id, msg("a2"));
184
+ expect(prompts(adapter.items)).toEqual(["a2", "b"]);
185
+ expect(adapter.items[0]!.id).toBe(id);
95
186
  });
96
187
 
97
- it("steers via cancel and suppresses the cancelled run's idle (no double-dequeue)", () => {
188
+ it("edit throws on an unknown queue item", () => {
189
+ const { adapter } = createMessageQueue({ run: vi.fn() });
190
+ expect(() => adapter.edit("nope", msg("x"))).toThrow(
191
+ 'Unknown queue item "nope"',
192
+ );
193
+ });
194
+
195
+ it("steer interrupts via cancel and suppresses the cancelled run's idle", () => {
98
196
  const run = vi.fn();
99
197
  const cancel = vi.fn();
100
198
  const { adapter, notifyIdle } = createMessageQueue({ run, cancel });
101
199
 
102
- adapter.enqueue(msg("a"), { steer: false }); // running
103
- adapter.enqueue(msg("b"), { steer: false }); // queued
104
- adapter.enqueue(msg("steer-me"), { steer: true });
200
+ adapter.enqueue(msg("a")); // running
201
+ adapter.enqueue(msg("b")); // queued
202
+ adapter.steer(msg("steer-me"));
105
203
 
106
204
  expect(cancel).toHaveBeenCalledTimes(1);
107
205
  expect(run).toHaveBeenLastCalledWith(
@@ -124,16 +222,16 @@ describe("createMessageQueue", () => {
124
222
  );
125
223
  });
126
224
 
127
- it("degrades steer to run-next when no cancel is available", () => {
225
+ it("steer without cancel dispatches next, before the queue lane", () => {
128
226
  const run = vi.fn();
129
227
  const { adapter, notifyIdle } = createMessageQueue({ run }); // no cancel
130
228
 
131
- adapter.enqueue(msg("a"), { steer: false }); // running
132
- adapter.enqueue(msg("b"), { steer: false });
133
- adapter.enqueue(msg("urgent"), { steer: true });
229
+ adapter.enqueue(msg("a")); // running
230
+ adapter.enqueue(msg("b"));
231
+ adapter.steer(msg("urgent"));
134
232
 
135
- // no interrupt: "urgent" jumps the queue ahead of "b"
136
- expect(adapter.items.map((i) => i.prompt)).toEqual(["urgent", "b"]);
233
+ expect(prompts(adapter.steerItems)).toEqual(["urgent"]);
234
+ expect(prompts(adapter.items)).toEqual(["b"]);
137
235
 
138
236
  notifyIdle();
139
237
  expect(run).toHaveBeenLastCalledWith(
@@ -142,13 +240,375 @@ describe("createMessageQueue", () => {
142
240
  );
143
241
  });
144
242
 
243
+ it("advance pops the steer lane before the queue lane", () => {
244
+ const order: string[] = [];
245
+ const run = vi.fn((m: AppendMessage) =>
246
+ order.push((m.content[0] as { text: string }).text),
247
+ );
248
+ const { adapter, notifyBusy, notifyIdle } = createMessageQueue({ run });
249
+
250
+ notifyBusy();
251
+ adapter.enqueue(msg("q1"));
252
+ adapter.steer(msg("s1"));
253
+ adapter.steer(msg("s2"));
254
+
255
+ notifyIdle();
256
+ notifyIdle();
257
+ notifyIdle();
258
+ expect(order).toEqual(["s1", "s2", "q1"]);
259
+ });
260
+
261
+ describe("move", () => {
262
+ const setup = () => {
263
+ const run = vi.fn();
264
+ const queue = createMessageQueue({ run });
265
+ queue.notifyBusy();
266
+ queue.adapter.enqueue(msg("a"));
267
+ queue.adapter.enqueue(msg("b"));
268
+ queue.adapter.enqueue(msg("c"));
269
+ queue.adapter.steer(msg("s"));
270
+ const id = (prompt: string) =>
271
+ [...queue.adapter.steerItems, ...queue.adapter.items].find(
272
+ (i) => i.prompt === prompt,
273
+ )!.id;
274
+ return { ...queue, id, run };
275
+ };
276
+
277
+ it("keeps position within the same lane when no placement is given", () => {
278
+ const { adapter, id } = setup();
279
+ adapter.move(id("b"), {});
280
+ expect(prompts(adapter.items)).toEqual(["a", "b", "c"]);
281
+ });
282
+
283
+ it("moves to the destination lane tail on lane change without placement", () => {
284
+ const { adapter, id } = setup();
285
+ adapter.move(id("s"), { lane: "queue" });
286
+ expect(prompts(adapter.steerItems)).toEqual([]);
287
+ expect(prompts(adapter.items)).toEqual(["a", "b", "c", "s"]);
288
+ });
289
+
290
+ it("moves into the steer lane without interrupting when no cancel is available", () => {
291
+ const { adapter, id } = setup();
292
+ adapter.move(id("b"), { lane: "steer" });
293
+ expect(prompts(adapter.steerItems)).toEqual(["s", "b"]);
294
+ expect(prompts(adapter.items)).toEqual(["a", "c"]);
295
+ });
296
+
297
+ it("reorders within the steer lane", () => {
298
+ const { adapter, id } = setup();
299
+ adapter.steer(msg("s2"));
300
+ adapter.move(id("s2"), { insertBefore: id("s") });
301
+ expect(prompts(adapter.steerItems)).toEqual(["s2", "s"]);
302
+ expect(prompts(adapter.items)).toEqual(["a", "b", "c"]);
303
+ });
304
+
305
+ it("insertAfter: null moves to the front of the lane", () => {
306
+ const { adapter, id } = setup();
307
+ adapter.move(id("c"), { insertAfter: null });
308
+ expect(prompts(adapter.items)).toEqual(["c", "a", "b"]);
309
+ });
310
+
311
+ it("insertBefore: null moves to the end of the lane", () => {
312
+ const { adapter, id } = setup();
313
+ adapter.move(id("a"), { insertBefore: null });
314
+ expect(prompts(adapter.items)).toEqual(["b", "c", "a"]);
315
+ });
316
+
317
+ it("insertAfter an anchor id places the item right after it", () => {
318
+ const { adapter, id } = setup();
319
+ adapter.move(id("a"), { insertAfter: id("b") });
320
+ expect(prompts(adapter.items)).toEqual(["b", "a", "c"]);
321
+ });
322
+
323
+ it("insertBefore an anchor id places the item right before it", () => {
324
+ const { adapter, id } = setup();
325
+ adapter.move(id("c"), { insertBefore: id("b") });
326
+ expect(prompts(adapter.items)).toEqual(["a", "c", "b"]);
327
+ });
328
+
329
+ it("accepts an adjacent insertAfter/insertBefore pair", () => {
330
+ const { adapter, id } = setup();
331
+ adapter.move(id("c"), { insertAfter: id("a"), insertBefore: id("b") });
332
+ expect(prompts(adapter.items)).toEqual(["a", "c", "b"]);
333
+ });
334
+
335
+ it("throws when the insertAfter/insertBefore pair is not adjacent", () => {
336
+ const { adapter, id } = setup();
337
+ expect(() =>
338
+ adapter.move(id("b"), { insertAfter: id("c"), insertBefore: id("a") }),
339
+ ).toThrow("not adjacent");
340
+ });
341
+
342
+ it("throws on an unknown queue item", () => {
343
+ const { adapter } = setup();
344
+ expect(() => adapter.move("nope", {})).toThrow(
345
+ 'Unknown queue item "nope"',
346
+ );
347
+ });
348
+
349
+ it("throws on an unknown anchor", () => {
350
+ const { adapter, id } = setup();
351
+ expect(() => adapter.move(id("a"), { insertAfter: "nope" })).toThrow(
352
+ 'Unknown anchor "nope"',
353
+ );
354
+ });
355
+
356
+ it("throws on a self-anchor", () => {
357
+ const { adapter, id } = setup();
358
+ expect(() => adapter.move(id("a"), { insertAfter: id("a") })).toThrow(
359
+ "cannot anchor itself",
360
+ );
361
+ });
362
+
363
+ it("throws when the anchor lives in a different lane", () => {
364
+ const { adapter, id } = setup();
365
+ expect(() =>
366
+ adapter.move(id("s"), { lane: "queue", insertAfter: id("s") }),
367
+ ).toThrow("cannot anchor itself");
368
+ expect(() => adapter.move(id("a"), { insertAfter: id("s") })).toThrow(
369
+ "Unknown anchor",
370
+ );
371
+ });
372
+
373
+ it("interrupts via cancel when moving into the steer lane mid-run", () => {
374
+ const run = vi.fn();
375
+ const cancel = vi.fn();
376
+ const { adapter, notifyIdle } = createMessageQueue({ run, cancel });
377
+
378
+ adapter.enqueue(msg("a")); // running
379
+ adapter.enqueue(msg("b"));
380
+ adapter.enqueue(msg("c"));
381
+
382
+ adapter.move(adapter.items[1]!.id, { lane: "steer" }); // "c"
383
+ expect(cancel).toHaveBeenCalledTimes(1);
384
+ expect(run).toHaveBeenLastCalledWith(
385
+ expect.objectContaining({ content: [{ type: "text", text: "c" }] }),
386
+ { steer: true },
387
+ );
388
+ expect(prompts(adapter.items)).toEqual(["b"]);
389
+
390
+ // cancelled run's settle is swallowed
391
+ notifyIdle();
392
+ expect(run).toHaveBeenCalledTimes(2);
393
+ });
394
+
395
+ it("validates anchors mid-run instead of interrupting past them", () => {
396
+ const run = vi.fn();
397
+ const cancel = vi.fn();
398
+ const { adapter } = createMessageQueue({ run, cancel });
399
+
400
+ adapter.enqueue(msg("a")); // running
401
+ adapter.enqueue(msg("b"));
402
+ const bId = adapter.items[0]!.id;
403
+
404
+ expect(() =>
405
+ adapter.move(bId, { lane: "steer", insertAfter: "nope" }),
406
+ ).toThrow('Unknown anchor "nope"');
407
+ expect(() =>
408
+ adapter.move(bId, { lane: "steer", insertAfter: bId }),
409
+ ).toThrow("cannot anchor itself");
410
+ expect(cancel).not.toHaveBeenCalled();
411
+ expect(run).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it("places an anchored move into the steer lane mid-run without interrupting", () => {
415
+ const run = vi.fn();
416
+ const cancel = vi.fn();
417
+ const { adapter, notifyIdle } = createMessageQueue({ run, cancel });
418
+
419
+ adapter.enqueue(msg("a")); // running
420
+ adapter.enqueue(msg("b"));
421
+ adapter.enqueue(msg("c"));
422
+
423
+ adapter.move(adapter.items[1]!.id, { lane: "steer", insertAfter: null }); // "c"
424
+ expect(cancel).not.toHaveBeenCalled();
425
+ expect(run).toHaveBeenCalledTimes(1);
426
+ expect(prompts(adapter.steerItems)).toEqual(["c"]);
427
+ expect(prompts(adapter.items)).toEqual(["b"]);
428
+
429
+ // the placed item dispatches first once the live run settles
430
+ notifyIdle();
431
+ expect(run).toHaveBeenLastCalledWith(
432
+ expect.objectContaining({ content: [{ type: "text", text: "c" }] }),
433
+ { steer: false },
434
+ );
435
+ });
436
+ });
437
+
438
+ it("notifyCancelled keeps items and pauses advance until the next send", () => {
439
+ const run = vi.fn();
440
+ const { adapter, notifyIdle, notifyCancelled } = createMessageQueue({
441
+ run,
442
+ });
443
+
444
+ adapter.enqueue(msg("a")); // running
445
+ adapter.enqueue(msg("b"));
446
+ adapter.enqueue(msg("c"));
447
+
448
+ notifyCancelled();
449
+ notifyIdle(); // the cancelled run settles
450
+ expect(run).toHaveBeenCalledTimes(1);
451
+ expect(prompts(adapter.items)).toEqual(["b", "c"]);
452
+
453
+ // the next explicit send re-arms draining, head first
454
+ adapter.enqueue(msg("d"));
455
+ expect(run).toHaveBeenLastCalledWith(
456
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
457
+ { steer: false },
458
+ );
459
+ expect(prompts(adapter.items)).toEqual(["c", "d"]);
460
+ });
461
+
462
+ it("a run started after a cancel re-arms draining", () => {
463
+ const run = vi.fn();
464
+ const { adapter, notifyBusy, notifyIdle, notifyCancelled } =
465
+ createMessageQueue({ run });
466
+
467
+ adapter.enqueue(msg("a")); // running
468
+ adapter.enqueue(msg("b"));
469
+
470
+ notifyCancelled();
471
+ notifyIdle(); // the cancelled run settles
472
+ expect(run).toHaveBeenCalledTimes(1);
473
+
474
+ notifyBusy(); // regenerate
475
+ notifyIdle();
476
+ expect(run).toHaveBeenCalledTimes(2);
477
+ expect(run).toHaveBeenLastCalledWith(
478
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
479
+ { steer: false },
480
+ );
481
+ });
482
+
483
+ it("swallows the cancelled run's settle when a replacement run starts first", () => {
484
+ const run = vi.fn();
485
+ const { adapter, notifyBusy, notifyIdle, notifyCancelled } =
486
+ createMessageQueue({ run });
487
+
488
+ adapter.enqueue(msg("a")); // running
489
+ adapter.enqueue(msg("b"));
490
+
491
+ notifyCancelled();
492
+ notifyBusy(); // a regenerate starts before the cancelled run settles
493
+ notifyIdle(); // the cancelled run's late settle
494
+ expect(run).toHaveBeenCalledTimes(1); // nothing dispatches mid-run
495
+
496
+ notifyIdle(); // the replacement run settles
497
+ expect(run).toHaveBeenCalledTimes(2);
498
+ expect(run).toHaveBeenLastCalledWith(
499
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
500
+ { steer: false },
501
+ );
502
+ });
503
+
504
+ it("a send during the cancellation window drains once the cancelled run settles", () => {
505
+ const run = vi.fn();
506
+ const { adapter, notifyIdle, notifyCancelled } = createMessageQueue({
507
+ run,
508
+ });
509
+
510
+ adapter.enqueue(msg("a")); // running
511
+ adapter.enqueue(msg("b"));
512
+
513
+ notifyCancelled();
514
+ adapter.enqueue(msg("c")); // re-arms before the cancelled run settles
515
+ expect(run).toHaveBeenCalledTimes(1);
516
+
517
+ notifyIdle(); // the cancelled run settles
518
+ expect(run).toHaveBeenCalledTimes(2);
519
+ expect(run).toHaveBeenLastCalledWith(
520
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
521
+ { steer: false },
522
+ );
523
+ });
524
+
525
+ it("steering during the cancellation window swallows only the cancelled settle", () => {
526
+ const run = vi.fn();
527
+ const cancel = vi.fn();
528
+ const { adapter, notifyIdle, notifyCancelled } = createMessageQueue({
529
+ run,
530
+ cancel,
531
+ });
532
+
533
+ adapter.enqueue(msg("a")); // running
534
+ adapter.enqueue(msg("b"));
535
+
536
+ notifyCancelled();
537
+ adapter.steer(msg("s")); // interrupts before the cancelled run settles
538
+ expect(run).toHaveBeenCalledTimes(2);
539
+
540
+ notifyIdle(); // the cancelled run's settle is swallowed
541
+ expect(run).toHaveBeenCalledTimes(2);
542
+
543
+ notifyIdle(); // the steered run settles and draining resumes
544
+ expect(run).toHaveBeenCalledTimes(3);
545
+ expect(run).toHaveBeenLastCalledWith(
546
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
547
+ { steer: false },
548
+ );
549
+ });
550
+
551
+ it("a repeated cancel counts a single settle", () => {
552
+ const run = vi.fn();
553
+ const { adapter, notifyBusy, notifyIdle, notifyCancelled } =
554
+ createMessageQueue({ run });
555
+
556
+ adapter.enqueue(msg("a")); // running
557
+ adapter.enqueue(msg("b"));
558
+
559
+ notifyCancelled();
560
+ notifyCancelled();
561
+ notifyBusy(); // a replacement run starts inside the cancellation window
562
+
563
+ notifyIdle(); // the cancelled run's settle is swallowed exactly once
564
+ expect(run).toHaveBeenCalledTimes(1);
565
+
566
+ notifyIdle(); // the replacement run settles and draining resumes
567
+ expect(run).toHaveBeenCalledTimes(2);
568
+ expect(run).toHaveBeenLastCalledWith(
569
+ expect.objectContaining({ content: [{ type: "text", text: "b" }] }),
570
+ { steer: false },
571
+ );
572
+ });
573
+
574
+ it("clear empties both lanes without dispatching", () => {
575
+ const run = vi.fn();
576
+ const { adapter, notifyBusy, clear } = createMessageQueue({ run });
577
+
578
+ notifyBusy();
579
+ adapter.enqueue(msg("a"));
580
+ adapter.steer(msg("s"));
581
+ clear();
582
+
583
+ expect(adapter.items).toHaveLength(0);
584
+ expect(adapter.steerItems).toHaveLength(0);
585
+ expect(run).not.toHaveBeenCalled();
586
+ });
587
+
588
+ it("dispatches sends normally after clear", () => {
589
+ const run = vi.fn();
590
+ const { adapter, notifyIdle, clear } = createMessageQueue({ run });
591
+
592
+ adapter.enqueue(msg("a")); // running
593
+ adapter.enqueue(msg("b"));
594
+ clear();
595
+ notifyIdle();
596
+ expect(run).toHaveBeenCalledTimes(1);
597
+
598
+ adapter.enqueue(msg("c"));
599
+ expect(run).toHaveBeenLastCalledWith(
600
+ expect.objectContaining({ content: [{ type: "text", text: "c" }] }),
601
+ { steer: false },
602
+ );
603
+ });
604
+
145
605
  it("notifies subscribers on item changes", () => {
146
606
  const run = vi.fn();
147
607
  const { adapter, subscribe } = createMessageQueue({ run });
148
608
  const cb = vi.fn();
149
609
  subscribe(cb);
150
610
 
151
- adapter.enqueue(msg("a"), { steer: false }); // runs (1 setItems push + 1 pop)
611
+ adapter.enqueue(msg("a")); // runs (1 setLanes push + 1 pop)
152
612
  expect(cb).toHaveBeenCalled();
153
613
  });
154
614
 
@@ -157,9 +617,9 @@ describe("createMessageQueue", () => {
157
617
  const { adapter, notifyBusy, notifyIdle } = createMessageQueue({ run });
158
618
 
159
619
  notifyBusy(); // e.g. a regenerate started without going through the queue
160
- adapter.enqueue(msg("a"), { steer: false });
620
+ adapter.enqueue(msg("a"));
161
621
  expect(run).not.toHaveBeenCalled();
162
- expect(adapter.items.map((i) => i.prompt)).toEqual(["a"]);
622
+ expect(prompts(adapter.items)).toEqual(["a"]);
163
623
 
164
624
  notifyIdle(); // that run settled
165
625
  expect(run).toHaveBeenCalledTimes(1);
@@ -174,6 +174,72 @@ describe("fromThreadMessageLike", () => {
174
174
  { type: "image", image: "DATA:IMAGE/PNG;base64,AAAA" },
175
175
  ]);
176
176
  });
177
+
178
+ it.each([
179
+ "data:image/avif;base64,AAAA",
180
+ "data:image/bmp;base64,AAAA",
181
+ "data:image/heic;base64,AAAA",
182
+ "data:image/heif;base64,AAAA",
183
+ "data:image/tiff;base64,AAAA",
184
+ ])("keeps an image part with a %s data URL", (image) => {
185
+ const result = fromThreadMessageLike(
186
+ { role: "assistant", content: [{ type: "image", image }] },
187
+ fallbackId,
188
+ fallbackStatus,
189
+ );
190
+
191
+ expect(result.content).toEqual([{ type: "image", image }]);
192
+ });
193
+
194
+ it("keeps an image part with a parameterized data URL", () => {
195
+ const image = "data:image/png;charset=utf-8;base64,AAAA";
196
+ const result = fromThreadMessageLike(
197
+ { role: "assistant", content: [{ type: "image", image }] },
198
+ fallbackId,
199
+ fallbackStatus,
200
+ );
201
+
202
+ expect(result.content).toEqual([{ type: "image", image }]);
203
+ });
204
+
205
+ it("drops an image part with a non-image data URL", () => {
206
+ const result = fromThreadMessageLike(
207
+ {
208
+ role: "assistant",
209
+ content: [{ type: "image", image: "data:text/plain;base64,AAAA" }],
210
+ },
211
+ fallbackId,
212
+ fallbackStatus,
213
+ );
214
+
215
+ expect(result.content).toEqual([]);
216
+ });
217
+
218
+ it("drops an image part with a non-base64 data URL", () => {
219
+ const result = fromThreadMessageLike(
220
+ {
221
+ role: "assistant",
222
+ content: [{ type: "image", image: "data:image/png,rawpayload" }],
223
+ },
224
+ fallbackId,
225
+ fallbackStatus,
226
+ );
227
+
228
+ expect(result.content).toEqual([]);
229
+ });
230
+
231
+ it("drops an image part with a non-URL string", () => {
232
+ const result = fromThreadMessageLike(
233
+ {
234
+ role: "assistant",
235
+ content: [{ type: "image", image: "not-an-image" }],
236
+ },
237
+ fallbackId,
238
+ fallbackStatus,
239
+ );
240
+
241
+ expect(result.content).toEqual([]);
242
+ });
177
243
  });
178
244
 
179
245
  describe("providerMetadata passthrough", () => {