@marimo-team/islands 0.23.9-dev12 → 0.23.9-dev14

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.
@@ -0,0 +1,278 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { UIMessageChunk } from "ai";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import { routeIncomingChatChunk } from "../chat-ui";
6
+
7
+ /**
8
+ * The stale-chunk filter prevents chunks from an aborted run from being enqueued into a new run's stream.
9
+ *
10
+ * It triggers when:
11
+ * 1. User sends a prompt (request_id = OLD), kernel starts emitting chunks
12
+ * 2. User clicks Stop — frontend tears down its controller, fires cancel_prompt
13
+ * 3. Kernel hasn't received the cancel yet and is still emitting chunks
14
+ * 4. User sends a new prompt (request_id = NEW), new controller opens
15
+ * 5. Late chunks tagged OLD arrive after NEW's controller is in place
16
+ */
17
+
18
+ const makeChunk = (opts: {
19
+ messageId: string;
20
+ content: unknown;
21
+ isFinal?: boolean;
22
+ }): Parameters<typeof routeIncomingChatChunk>[0] => ({
23
+ type: "stream_chunk",
24
+ message_id: opts.messageId,
25
+ content: opts.content as UIMessageChunk | null,
26
+ is_final: opts.isFinal ?? false,
27
+ });
28
+
29
+ const makeRefs = () => ({
30
+ controllerRef: {
31
+ current: null as ReadableStreamDefaultController<UIMessageChunk> | null,
32
+ },
33
+ activeRequestIdRef: { current: null as string | null },
34
+ });
35
+
36
+ const makeMockController = () => {
37
+ return {
38
+ enqueue: vi.fn(),
39
+ close: vi.fn(),
40
+ error: vi.fn(),
41
+ desiredSize: 0,
42
+ } as unknown as ReadableStreamDefaultController<UIMessageChunk> & {
43
+ enqueue: ReturnType<typeof vi.fn>;
44
+ close: ReturnType<typeof vi.fn>;
45
+ };
46
+ };
47
+
48
+ describe("routeIncomingChatChunk", () => {
49
+ it("drops chunks when there is no active controller", () => {
50
+ const refs = makeRefs();
51
+
52
+ const result = routeIncomingChatChunk(
53
+ makeChunk({
54
+ messageId: "req-A",
55
+ content: { type: "text-delta", id: "t1", delta: "hi" },
56
+ }),
57
+ refs,
58
+ );
59
+
60
+ expect(result).toBe("dropped-no-controller");
61
+ });
62
+
63
+ it("enqueues chunks that match the active request_id", () => {
64
+ const refs = makeRefs();
65
+ const controller = makeMockController();
66
+ refs.controllerRef.current = controller;
67
+ refs.activeRequestIdRef.current = "req-A";
68
+
69
+ const chunk = { type: "text-delta", id: "t1", delta: "hi" } as const;
70
+ const result = routeIncomingChatChunk(
71
+ makeChunk({ messageId: "req-A", content: chunk }),
72
+ refs,
73
+ );
74
+
75
+ expect(result).toBe("enqueued");
76
+ expect(controller.enqueue).toHaveBeenCalledWith(chunk);
77
+ expect(controller.close).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("closes the controller and clears refs on is_final", () => {
81
+ const refs = makeRefs();
82
+ const controller = makeMockController();
83
+ refs.controllerRef.current = controller;
84
+ refs.activeRequestIdRef.current = "req-A";
85
+
86
+ const result = routeIncomingChatChunk(
87
+ makeChunk({ messageId: "req-A", content: null, isFinal: true }),
88
+ refs,
89
+ );
90
+
91
+ expect(result).toBe("closed");
92
+ expect(controller.close).toHaveBeenCalledTimes(1);
93
+ expect(refs.controllerRef.current).toBeNull();
94
+ expect(refs.activeRequestIdRef.current).toBeNull();
95
+ });
96
+
97
+ it("drops chunks whose message_id does not match the active run", () => {
98
+ // Simulates the bug: kernel hasn't received cancel for OLD yet but the
99
+ // user has already started a NEW run. A reasoning-delta for OLD arrives
100
+ // here; it must not be enqueued into NEW's stream.
101
+ const refs = makeRefs();
102
+ const controller = makeMockController();
103
+ refs.controllerRef.current = controller;
104
+ refs.activeRequestIdRef.current = "req-NEW";
105
+
106
+ const staleChunk = {
107
+ type: "reasoning-delta",
108
+ id: "r-old",
109
+ delta: "...",
110
+ } as const;
111
+ const result = routeIncomingChatChunk(
112
+ makeChunk({ messageId: "req-OLD", content: staleChunk }),
113
+ refs,
114
+ );
115
+
116
+ expect(result).toBe("dropped-stale");
117
+ expect(controller.enqueue).not.toHaveBeenCalled();
118
+ expect(controller.close).not.toHaveBeenCalled();
119
+ expect(refs.activeRequestIdRef.current).toBe("req-NEW");
120
+ });
121
+
122
+ it("drops is_final from a stale run without closing the active stream", () => {
123
+ // Belt-and-suspenders: an `is_final` for OLD that races in after NEW
124
+ // started must not tear down NEW's controller.
125
+ const refs = makeRefs();
126
+ const controller = makeMockController();
127
+ refs.controllerRef.current = controller;
128
+ refs.activeRequestIdRef.current = "req-NEW";
129
+
130
+ const result = routeIncomingChatChunk(
131
+ makeChunk({ messageId: "req-OLD", content: null, isFinal: true }),
132
+ refs,
133
+ );
134
+
135
+ expect(result).toBe("dropped-stale");
136
+ expect(controller.close).not.toHaveBeenCalled();
137
+ expect(refs.controllerRef.current).toBe(controller);
138
+ expect(refs.activeRequestIdRef.current).toBe("req-NEW");
139
+ });
140
+
141
+ it("forwards reasoning-start/delta/end sequences when ids match", () => {
142
+ // Walks the canonical happy path for a reasoning stream end-to-end.
143
+ const refs = makeRefs();
144
+ const controller = makeMockController();
145
+ refs.controllerRef.current = controller;
146
+ refs.activeRequestIdRef.current = "req-A";
147
+
148
+ const sequence = [
149
+ { type: "reasoning-start", id: "r1" },
150
+ { type: "reasoning-delta", id: "r1", delta: "thinking" },
151
+ { type: "reasoning-end", id: "r1" },
152
+ ] as const;
153
+ for (const chunk of sequence) {
154
+ const result = routeIncomingChatChunk(
155
+ makeChunk({ messageId: "req-A", content: chunk }),
156
+ refs,
157
+ );
158
+ expect(result).toBe("enqueued");
159
+ }
160
+
161
+ expect(controller.enqueue).toHaveBeenCalledTimes(3);
162
+ expect(controller.enqueue).toHaveBeenNthCalledWith(1, sequence[0]);
163
+ expect(controller.enqueue).toHaveBeenNthCalledWith(2, sequence[1]);
164
+ expect(controller.enqueue).toHaveBeenNthCalledWith(3, sequence[2]);
165
+ });
166
+
167
+ it(
168
+ "drops stale reasoning-delta after Stop → new run sequence " +
169
+ "(regression for missing reasoning part error)",
170
+ () => {
171
+ // Full scenario: A runs, A is stopped, B starts, A's late chunk arrives.
172
+ const refs = makeRefs();
173
+
174
+ // 1. Run A starts: controller A active.
175
+ const controllerA = makeMockController();
176
+ refs.controllerRef.current = controllerA;
177
+ refs.activeRequestIdRef.current = "req-A";
178
+
179
+ // First reasoning chunks for A flow through.
180
+ routeIncomingChatChunk(
181
+ makeChunk({
182
+ messageId: "req-A",
183
+ content: { type: "reasoning-start", id: "rA" },
184
+ }),
185
+ refs,
186
+ );
187
+ routeIncomingChatChunk(
188
+ makeChunk({
189
+ messageId: "req-A",
190
+ content: {
191
+ type: "reasoning-delta",
192
+ id: "rA",
193
+ delta: "thinking",
194
+ },
195
+ }),
196
+ refs,
197
+ );
198
+ expect(controllerA.enqueue).toHaveBeenCalledTimes(2);
199
+
200
+ // 2. User clicks Stop: abort handler clears refs (simulated).
201
+ refs.controllerRef.current = null;
202
+ refs.activeRequestIdRef.current = null;
203
+
204
+ // A late chunk for A arrives in this window — must be a no-op.
205
+ const between = routeIncomingChatChunk(
206
+ makeChunk({
207
+ messageId: "req-A",
208
+ content: {
209
+ type: "reasoning-delta",
210
+ id: "rA",
211
+ delta: "leftover",
212
+ },
213
+ }),
214
+ refs,
215
+ );
216
+ expect(between).toBe("dropped-no-controller");
217
+
218
+ // 3. User sends Run B: new controller, new active id.
219
+ const controllerB = makeMockController();
220
+ refs.controllerRef.current = controllerB;
221
+ refs.activeRequestIdRef.current = "req-B";
222
+
223
+ // 4. Another late chunk for A arrives AFTER B opened. This is the
224
+ // case that previously threw `Received reasoning-delta for missing
225
+ // reasoning part with ID "rA"` in the SDK parser.
226
+ const stale = routeIncomingChatChunk(
227
+ makeChunk({
228
+ messageId: "req-A",
229
+ content: {
230
+ type: "reasoning-delta",
231
+ id: "rA",
232
+ delta: "still leaking",
233
+ },
234
+ }),
235
+ refs,
236
+ );
237
+ expect(stale).toBe("dropped-stale");
238
+ expect(controllerB.enqueue).not.toHaveBeenCalled();
239
+
240
+ // 5. B's own chunks flow normally.
241
+ routeIncomingChatChunk(
242
+ makeChunk({
243
+ messageId: "req-B",
244
+ content: { type: "reasoning-start", id: "rB" },
245
+ }),
246
+ refs,
247
+ );
248
+ routeIncomingChatChunk(
249
+ makeChunk({
250
+ messageId: "req-B",
251
+ content: { type: "reasoning-delta", id: "rB", delta: "fresh" },
252
+ }),
253
+ refs,
254
+ );
255
+ expect(controllerB.enqueue).toHaveBeenCalledTimes(2);
256
+ },
257
+ );
258
+
259
+ it("enqueues content alongside is_final and then closes", () => {
260
+ // Sanity: a single chunk that carries both `content` and `is_final` (rare
261
+ // but legal — backend may bundle final content with the terminator)
262
+ // should enqueue then close.
263
+ const refs = makeRefs();
264
+ const controller = makeMockController();
265
+ refs.controllerRef.current = controller;
266
+ refs.activeRequestIdRef.current = "req-A";
267
+
268
+ const chunk = { type: "text-delta", id: "t1", delta: "bye" } as const;
269
+ const result = routeIncomingChatChunk(
270
+ makeChunk({ messageId: "req-A", content: chunk, isFinal: true }),
271
+ refs,
272
+ );
273
+
274
+ expect(result).toBe("closed");
275
+ expect(controller.enqueue).toHaveBeenCalledWith(chunk);
276
+ expect(controller.close).toHaveBeenCalledTimes(1);
277
+ });
278
+ });
@@ -20,13 +20,17 @@ import {
20
20
  RotateCwIcon,
21
21
  SendHorizontalIcon,
22
22
  SettingsIcon,
23
+ SquareIcon,
23
24
  Trash2Icon,
24
25
  X,
25
26
  } from "lucide-react";
26
27
  import React, { useEffect, useRef, useState } from "react";
27
28
  import { z } from "zod";
28
29
  import { renderUIMessage } from "@/components/chat/chat-display";
29
- import { convertToFileUIPart } from "@/components/chat/chat-utils";
30
+ import {
31
+ convertToFileUIPart,
32
+ hasPendingToolCalls,
33
+ } from "@/components/chat/chat-utils";
30
34
  import {
31
35
  type AdditionalCompletions,
32
36
  PromptInput,
@@ -60,6 +64,7 @@ import { cn } from "@/utils/cn";
60
64
  import { Logger } from "@/utils/Logger";
61
65
  import { Objects } from "@/utils/objects";
62
66
  import { Strings } from "@/utils/strings";
67
+ import { generateUUID } from "@/utils/uuid";
63
68
  import { ErrorBanner } from "../common/error-banner";
64
69
  import type { PluginFunctions } from "./ChatPlugin";
65
70
  import type { ChatConfig } from "./types";
@@ -86,6 +91,48 @@ const ChatMessageIncomingSchema = z.object({
86
91
  is_final: z.boolean().optional(),
87
92
  });
88
93
 
94
+ type ChatMessageIncoming = z.infer<typeof ChatMessageIncomingSchema>;
95
+
96
+ export interface IncomingChatChunkRefs {
97
+ controllerRef: {
98
+ current: ReadableStreamDefaultController<UIMessageChunk> | null;
99
+ };
100
+ activeRequestIdRef: { current: string | null };
101
+ }
102
+
103
+ /**
104
+ * Route a single incoming chunk to the active stream controller, dropping it
105
+ * if it belongs to a stale (aborted-but-not-yet-cancelled) backend run.
106
+ */
107
+ export function routeIncomingChatChunk(
108
+ message: ChatMessageIncoming,
109
+ refs: IncomingChatChunkRefs,
110
+ ): "enqueued" | "closed" | "dropped-no-controller" | "dropped-stale" {
111
+ const { controllerRef, activeRequestIdRef } = refs;
112
+ const controller = controllerRef.current;
113
+ if (controller === null) {
114
+ return "dropped-no-controller";
115
+ }
116
+ const activeRequestId = activeRequestIdRef.current;
117
+ if (activeRequestId !== null && message.message_id !== activeRequestId) {
118
+ Logger.debug("Dropping stale chat chunk", {
119
+ chunkRequestId: message.message_id,
120
+ activeRequestId,
121
+ });
122
+ return "dropped-stale";
123
+ }
124
+ if (message.content) {
125
+ controller.enqueue(message.content);
126
+ }
127
+ if (message.is_final) {
128
+ controller.close();
129
+ controllerRef.current = null;
130
+ activeRequestIdRef.current = null;
131
+ return "closed";
132
+ }
133
+ return "enqueued";
134
+ }
135
+
89
136
  export const Chatbot: React.FC<Props> = (props) => {
90
137
  const [input, setInput] = useState("");
91
138
  const [config, setConfig] = useState<ChatConfig>(props.config);
@@ -113,16 +160,15 @@ export const Chatbot: React.FC<Props> = (props) => {
113
160
  const configRef = useRef<ChatConfig>(config);
114
161
  configRef.current = config;
115
162
 
116
- // Track streaming state - maps backend message_id to frontend message index
117
- const streamingStateRef = useRef<{
118
- backendMessageId: string | null;
119
- frontendMessageIndex: number | null;
120
- }>({ backendMessageId: null, frontendMessageIndex: null });
121
-
122
163
  // For frontend-managed streaming, create a controller to enqueue chunks to.
123
164
  const frontendStreamControllerRef =
124
165
  useRef<ReadableStreamDefaultController<UIMessageChunk> | null>(null);
125
166
 
167
+ // The request_id of the currently-active prompt run. Chunks arriving with a
168
+ // different message_id are stale (from an aborted-but-not-yet-cancelled run
169
+ // on the kernel) and must be dropped
170
+ const activeRequestIdRef = useRef<string | null>(null);
171
+
126
172
  const { data: backendMessages } = useAsyncData(async () => {
127
173
  const response = await props.get_chat_history({});
128
174
  return response.messages;
@@ -143,7 +189,9 @@ export const Chatbot: React.FC<Props> = (props) => {
143
189
  error,
144
190
  regenerate,
145
191
  clearError,
192
+ addToolApprovalResponse,
146
193
  } = useChat({
194
+ sendAutomaticallyWhen: ({ messages }) => hasPendingToolCalls(messages),
147
195
  transport: new DefaultChatTransport({
148
196
  fetch: async (
149
197
  request: RequestInfo | URL,
@@ -180,17 +228,33 @@ export const Chatbot: React.FC<Props> = (props) => {
180
228
  };
181
229
  });
182
230
 
231
+ // Client-generated id used to (a) route chunks back to this stream
232
+ // and (b) ask the kernel to cancel just this run on Stop.
233
+ const requestId = generateUUID();
234
+
183
235
  const stream = new ReadableStream<UIMessageChunk>({
184
236
  start(controller) {
185
237
  frontendStreamControllerRef.current = controller;
238
+ activeRequestIdRef.current = requestId;
186
239
 
187
240
  const abortHandler = () => {
241
+ // Close the local controller first so the chat status flips to
242
+ // "ready" immediately and any racing chunks are dropped; then
243
+ // fire-and-forget the backend cancel so the kernel stops the
244
+ // model and we don't waste tokens / leak chunks to the next
245
+ // run.
188
246
  try {
189
247
  controller.close();
190
248
  } catch (error) {
191
249
  Logger.debug("Controller may already be closed", { error });
192
250
  }
193
251
  frontendStreamControllerRef.current = null;
252
+ activeRequestIdRef.current = null;
253
+ void props
254
+ .cancel_prompt({ request_id: requestId })
255
+ .catch((error: Error) => {
256
+ Logger.debug("cancel_prompt failed", { error });
257
+ });
194
258
  };
195
259
  signal?.addEventListener("abort", abortHandler);
196
260
 
@@ -200,28 +264,25 @@ export const Chatbot: React.FC<Props> = (props) => {
200
264
  },
201
265
  cancel() {
202
266
  frontendStreamControllerRef.current = null;
267
+ activeRequestIdRef.current = null;
203
268
  },
204
269
  });
205
270
 
206
271
  // Start the prompt, chunks will be sent via events
207
272
  void props
208
273
  .send_prompt({
274
+ request_id: requestId,
209
275
  messages: messages,
210
276
  config: chatConfig,
211
277
  })
212
278
  .catch((error: Error) => {
213
279
  frontendStreamControllerRef.current?.error(error);
214
280
  frontendStreamControllerRef.current = null;
281
+ activeRequestIdRef.current = null;
215
282
  });
216
283
 
217
284
  return createUIMessageStreamResponse({ stream });
218
285
  } catch (error: unknown) {
219
- // Clear streaming state on error
220
- streamingStateRef.current = {
221
- backendMessageId: null,
222
- frontendMessageIndex: null,
223
- };
224
-
225
286
  // Handle abort gracefully without showing an error
226
287
  if (error instanceof Error && error.name === "AbortError") {
227
288
  return new Response("Aborted", { status: 499 });
@@ -244,21 +305,10 @@ export const Chatbot: React.FC<Props> = (props) => {
244
305
  }
245
306
  Logger.debug("Finished streaming message:", message);
246
307
 
247
- // Clear streaming state
248
- streamingStateRef.current = {
249
- backendMessageId: null,
250
- frontendMessageIndex: null,
251
- };
252
-
253
308
  props.setValue(message.messages);
254
309
  },
255
310
  onError: (error) => {
256
311
  Logger.error("An error occurred:", error);
257
- // Clear streaming state on error
258
- streamingStateRef.current = {
259
- backendMessageId: null,
260
- frontendMessageIndex: null,
261
- };
262
312
  },
263
313
  });
264
314
 
@@ -273,23 +323,10 @@ export const Chatbot: React.FC<Props> = (props) => {
273
323
  if (!parsedMessage.success) {
274
324
  return;
275
325
  }
276
- const message = parsedMessage.data;
277
-
278
- // Push to the stream for useChat to process
279
- const controller = frontendStreamControllerRef.current;
280
- if (!controller) {
281
- return;
282
- }
283
-
284
- if (message.content) {
285
- controller.enqueue(message.content);
286
- }
287
- if (message.is_final) {
288
- controller.close();
289
- frontendStreamControllerRef.current = null;
290
- }
291
-
292
- return;
326
+ routeIncomingChatChunk(parsedMessage.data, {
327
+ controllerRef: frontendStreamControllerRef,
328
+ activeRequestIdRef,
329
+ });
293
330
  },
294
331
  );
295
332
 
@@ -408,6 +445,9 @@ export const Chatbot: React.FC<Props> = (props) => {
408
445
  message,
409
446
  isStreamingReasoning: status === "streaming",
410
447
  isLast,
448
+ addToolApprovalResponse: isLast
449
+ ? addToolApprovalResponse
450
+ : undefined,
411
451
  })}
412
452
  </div>
413
453
  <div className="flex justify-end text-xs gap-2 invisible group-hover:visible">
@@ -429,16 +469,8 @@ export const Chatbot: React.FC<Props> = (props) => {
429
469
  })}
430
470
 
431
471
  {isLoading && (
432
- <div className="flex items-center justify-center space-x-2 mb-4">
472
+ <div className="flex items-center justify-center mb-4">
433
473
  <Spinner size="small" />
434
- <Button
435
- variant="link"
436
- size="sm"
437
- onClick={() => stop()}
438
- className="text-(--red-9) hover:text-(--red-11)"
439
- >
440
- Stop
441
- </Button>
442
474
  </div>
443
475
  )}
444
476
 
@@ -569,15 +601,30 @@ export const Chatbot: React.FC<Props> = (props) => {
569
601
  />
570
602
  </>
571
603
  )}
572
- <Button
573
- type="submit"
574
- disabled={isLoading || !input}
575
- variant="outline"
576
- size="xs"
577
- className="text-(--slate-11)"
578
- >
579
- <SendHorizontalIcon className="h-4 w-4" />
580
- </Button>
604
+ {isLoading ? (
605
+ <Tooltip content="Stop generating">
606
+ <Button
607
+ type="button"
608
+ variant="link"
609
+ size="xs"
610
+ onClick={() => stop()}
611
+ className="text-(--red-9) hover:text-(--red-11)"
612
+ >
613
+ <SquareIcon className="h-4 w-4 fill-current" />
614
+ </Button>
615
+ </Tooltip>
616
+ ) : (
617
+ <Button
618
+ type="submit"
619
+ disabled={!input}
620
+ variant="outline"
621
+ size="xs"
622
+ className="text-(--slate-11)"
623
+ aria-label="Send message"
624
+ >
625
+ <SendHorizontalIcon className="h-4 w-4" />
626
+ </Button>
627
+ )}
581
628
  </form>
582
629
  </div>
583
630
  );
@@ -22,6 +22,11 @@ export interface ChatConfig {
22
22
  }
23
23
 
24
24
  export interface SendMessageRequest {
25
+ request_id: string;
25
26
  messages: ChatMessage[];
26
27
  config: ChatConfig;
27
28
  }
29
+
30
+ export interface CancelPromptRequest {
31
+ request_id: string;
32
+ }