@assistant-ui/react 0.12.14 → 0.12.15

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 (39) hide show
  1. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.d.ts.map +1 -1
  2. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js +143 -38
  3. package/dist/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.js.map +1 -1
  4. package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.d.ts.map +1 -1
  5. package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js +21 -9
  6. package/dist/legacy-runtime/runtime-cores/external-store/external-message-converter.js.map +1 -1
  7. package/dist/primitives/actionBar/ActionBarInteractionContext.d.ts +6 -0
  8. package/dist/primitives/actionBar/ActionBarInteractionContext.d.ts.map +1 -0
  9. package/dist/primitives/actionBar/ActionBarInteractionContext.js +5 -0
  10. package/dist/primitives/actionBar/ActionBarInteractionContext.js.map +1 -0
  11. package/dist/primitives/actionBar/ActionBarRoot.d.ts.map +1 -1
  12. package/dist/primitives/actionBar/ActionBarRoot.js +18 -4
  13. package/dist/primitives/actionBar/ActionBarRoot.js.map +1 -1
  14. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts +2 -1
  15. package/dist/primitives/actionBar/useActionBarFloatStatus.d.ts.map +1 -1
  16. package/dist/primitives/actionBar/useActionBarFloatStatus.js +3 -2
  17. package/dist/primitives/actionBar/useActionBarFloatStatus.js.map +1 -1
  18. package/dist/primitives/actionBarMore/ActionBarMoreRoot.d.ts.map +1 -1
  19. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js +35 -2
  20. package/dist/primitives/actionBarMore/ActionBarMoreRoot.js.map +1 -1
  21. package/dist/utils/json/is-json-equal.d.ts +2 -0
  22. package/dist/utils/json/is-json-equal.d.ts.map +1 -0
  23. package/dist/utils/json/is-json-equal.js +31 -0
  24. package/dist/utils/json/is-json-equal.js.map +1 -0
  25. package/dist/utils/json/is-json.d.ts +1 -0
  26. package/dist/utils/json/is-json.d.ts.map +1 -1
  27. package/dist/utils/json/is-json.js +5 -3
  28. package/dist/utils/json/is-json.js.map +1 -1
  29. package/package.json +6 -6
  30. package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.test.ts +225 -2
  31. package/src/legacy-runtime/runtime-cores/assistant-transport/useToolInvocations.ts +191 -50
  32. package/src/legacy-runtime/runtime-cores/external-store/external-message-converter.ts +28 -10
  33. package/src/primitives/actionBar/ActionBarInteractionContext.ts +13 -0
  34. package/src/primitives/actionBar/ActionBarRoot.tsx +38 -8
  35. package/src/primitives/actionBar/useActionBarFloatStatus.ts +4 -1
  36. package/src/primitives/actionBarMore/ActionBarMoreRoot.tsx +52 -2
  37. package/src/tests/external-message-converter.test.ts +80 -0
  38. package/src/utils/json/is-json-equal.ts +48 -0
  39. package/src/utils/json/is-json.ts +6 -3
@@ -14,6 +14,7 @@ import {
14
14
  AssistantMetaTransformStream,
15
15
  type ReadonlyJSONValue,
16
16
  } from "assistant-stream/utils";
17
+ import { isJSONValueEqual } from "../../../utils/json/is-json-equal";
17
18
 
18
19
  const isArgsTextComplete = (argsText: string) => {
19
20
  try {
@@ -24,6 +25,21 @@ const isArgsTextComplete = (argsText: string) => {
24
25
  }
25
26
  };
26
27
 
28
+ const parseArgsText = (argsText: string) => {
29
+ try {
30
+ return JSON.parse(argsText);
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ };
35
+
36
+ const isEquivalentCompleteArgsText = (previous: string, next: string) => {
37
+ const previousValue = parseArgsText(previous);
38
+ const nextValue = parseArgsText(next);
39
+ if (previousValue === undefined || nextValue === undefined) return false;
40
+ return isJSONValueEqual(previousValue, nextValue);
41
+ };
42
+
27
43
  type UseToolInvocationsParams = {
28
44
  state: AssistantTransportState;
29
45
  getTools: () => Record<string, Tool> | undefined;
@@ -69,6 +85,7 @@ export function useToolInvocations({
69
85
 
70
86
  const acRef = useRef<AbortController>(new AbortController());
71
87
  const executingCountRef = useRef(0);
88
+ const startedExecutionToolCallIdsRef = useRef<Set<string>>(new Set());
72
89
  const settledResolversRef = useRef<Array<() => void>>([]);
73
90
  const toolCallIdAliasesRef = useRef<Map<string, string>>(new Map());
74
91
  const ignoredResultToolCallIdsRef = useRef<Set<string>>(new Set());
@@ -151,6 +168,7 @@ export function useToolInvocations({
151
168
  if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
152
169
  return;
153
170
  }
171
+ startedExecutionToolCallIdsRef.current.add(toolCallId);
154
172
  const logicalToolCallId = getLogicalToolCallId(toolCallId);
155
173
  executingCountRef.current++;
156
174
  setToolStatuses((prev) => ({
@@ -159,7 +177,19 @@ export function useToolInvocations({
159
177
  }));
160
178
  },
161
179
  onExecutionEnd: (toolCallId: string) => {
180
+ const wasStarted =
181
+ startedExecutionToolCallIdsRef.current.delete(toolCallId);
162
182
  if (ignoredResultToolCallIdsRef.current.has(toolCallId)) {
183
+ if (wasStarted) {
184
+ executingCountRef.current--;
185
+ if (executingCountRef.current === 0) {
186
+ settledResolversRef.current.forEach((resolve) => resolve());
187
+ settledResolversRef.current = [];
188
+ }
189
+ }
190
+ return;
191
+ }
192
+ if (!wasStarted) {
163
193
  return;
164
194
  }
165
195
  const logicalToolCallId = getLogicalToolCallId(toolCallId);
@@ -244,6 +274,64 @@ export function useToolInvocations({
244
274
  return setToolState(toolCallId, { ...state, ...patch });
245
275
  };
246
276
 
277
+ const hasExecutableTool = (toolName: string) => {
278
+ const tool = getTools()?.[toolName];
279
+ return tool?.execute !== undefined || tool?.streamCall !== undefined;
280
+ };
281
+
282
+ const shouldCloseArgsStream = ({
283
+ toolName,
284
+ argsText,
285
+ hasResult,
286
+ }: {
287
+ toolName: string;
288
+ argsText: string;
289
+ hasResult: boolean;
290
+ }) => {
291
+ if (hasResult) return true;
292
+ if (!hasExecutableTool(toolName)) {
293
+ // Non-executable tools can emit parseable snapshots mid-stream.
294
+ // Wait until the run settles before closing the args stream.
295
+ return !state.isRunning && isArgsTextComplete(argsText);
296
+ }
297
+ return isArgsTextComplete(argsText);
298
+ };
299
+
300
+ const restartToolArgsStream = ({
301
+ toolCallId,
302
+ toolName,
303
+ state,
304
+ }: {
305
+ toolCallId: string;
306
+ toolName: string;
307
+ state: ToolState;
308
+ }) => {
309
+ ignoredResultToolCallIdsRef.current.add(state.streamToolCallId);
310
+ state.controller.argsText.close();
311
+
312
+ const streamToolCallId = `${toolCallId}:rewrite:${rewriteCounterRef.current++}`;
313
+ toolCallIdAliasesRef.current.set(streamToolCallId, toolCallId);
314
+ const toolCallController = controller.addToolCallPart({
315
+ toolName,
316
+ toolCallId: streamToolCallId,
317
+ });
318
+
319
+ if (process.env.NODE_ENV !== "production") {
320
+ console.warn("started replacement stream tool call", {
321
+ toolCallId,
322
+ streamToolCallId,
323
+ });
324
+ }
325
+
326
+ return setToolState(toolCallId, {
327
+ ...createToolState({
328
+ controller: toolCallController,
329
+ streamToolCallId,
330
+ }),
331
+ hasResult: state.hasResult,
332
+ });
333
+ };
334
+
247
335
  const processMessages = (
248
336
  messages: readonly (typeof state.messages)[number][],
249
337
  ) => {
@@ -276,89 +364,141 @@ export function useToolInvocations({
276
364
  }
277
365
 
278
366
  if (content.argsText !== lastState.argsText) {
367
+ let shouldWriteArgsText = true;
368
+
279
369
  if (lastState.argsComplete) {
280
- if (process.env.NODE_ENV !== "production") {
281
- console.warn(
282
- "argsText updated after controller was closed:",
283
- {
284
- previous: lastState.argsText,
285
- next: content.argsText,
286
- },
287
- );
370
+ if (
371
+ isEquivalentCompleteArgsText(
372
+ lastState.argsText,
373
+ content.argsText,
374
+ )
375
+ ) {
376
+ lastState = patchToolState(content.toolCallId, lastState, {
377
+ argsText: content.argsText,
378
+ });
379
+ shouldWriteArgsText = false;
288
380
  }
289
- } else {
290
- if (!content.argsText.startsWith(lastState.argsText)) {
291
- // Check if this is key reordering (both are complete JSON)
292
- // This happens when transitioning from streaming to complete state
293
- // and the provider returns keys in a different order
294
- if (
295
- isArgsTextComplete(lastState.argsText) &&
296
- isArgsTextComplete(content.argsText)
297
- ) {
298
- lastState.controller.argsText.close();
299
- patchToolState(content.toolCallId, lastState, {
300
- argsText: content.argsText,
301
- argsComplete: true,
302
- });
303
- return; // Continue to next content part
304
- }
381
+
382
+ if (shouldWriteArgsText) {
383
+ const canRestartClosedArgsStream =
384
+ !lastState.hasResult &&
385
+ !startedExecutionToolCallIdsRef.current.has(
386
+ lastState.streamToolCallId,
387
+ );
388
+
305
389
  if (process.env.NODE_ENV !== "production") {
306
390
  console.warn(
307
- "argsText rewrote previous snapshot, restarting tool args stream:",
391
+ canRestartClosedArgsStream
392
+ ? "argsText updated after controller was closed, restarting tool args stream:"
393
+ : "argsText updated after controller was closed:",
308
394
  {
309
395
  previous: lastState.argsText,
310
396
  next: content.argsText,
311
- toolCallId: content.toolCallId,
312
397
  },
313
398
  );
314
399
  }
315
400
 
316
- ignoredResultToolCallIdsRef.current.add(
317
- lastState.streamToolCallId,
318
- );
319
- lastState.controller.argsText.close();
401
+ if (!canRestartClosedArgsStream) {
402
+ lastState = patchToolState(
403
+ content.toolCallId,
404
+ lastState,
405
+ {
406
+ argsText: content.argsText,
407
+ },
408
+ );
409
+ shouldWriteArgsText = false;
410
+ }
411
+ }
320
412
 
321
- const streamToolCallId = `${content.toolCallId}:rewrite:${rewriteCounterRef.current++}`;
322
- toolCallIdAliasesRef.current.set(
323
- streamToolCallId,
324
- content.toolCallId,
325
- );
326
- const toolCallController = controller.addToolCallPart({
413
+ if (shouldWriteArgsText) {
414
+ lastState = restartToolArgsStream({
415
+ toolCallId: content.toolCallId,
327
416
  toolName: content.toolName,
328
- toolCallId: streamToolCallId,
417
+ state: lastState,
329
418
  });
419
+ }
420
+ } else if (!content.argsText.startsWith(lastState.argsText)) {
421
+ // Check if this is key reordering (both are complete JSON)
422
+ // This happens when transitioning from streaming to complete state
423
+ // and the provider returns keys in a different order
424
+ if (
425
+ isArgsTextComplete(lastState.argsText) &&
426
+ isArgsTextComplete(content.argsText) &&
427
+ isEquivalentCompleteArgsText(
428
+ lastState.argsText,
429
+ content.argsText,
430
+ )
431
+ ) {
432
+ const shouldClose = shouldCloseArgsStream({
433
+ toolName: content.toolName,
434
+ argsText: content.argsText,
435
+ hasResult: content.result !== undefined,
436
+ });
437
+ if (shouldClose) {
438
+ lastState.controller.argsText.close();
439
+ }
440
+ lastState = patchToolState(content.toolCallId, lastState, {
441
+ argsText: content.argsText,
442
+ argsComplete: shouldClose,
443
+ });
444
+ shouldWriteArgsText = false;
445
+ }
446
+ if (shouldWriteArgsText) {
330
447
  if (process.env.NODE_ENV !== "production") {
331
- console.warn("started replacement stream tool call", {
332
- toolCallId: content.toolCallId,
333
- streamToolCallId,
334
- });
448
+ console.warn(
449
+ "argsText rewrote previous snapshot, restarting tool args stream:",
450
+ {
451
+ previous: lastState.argsText,
452
+ next: content.argsText,
453
+ toolCallId: content.toolCallId,
454
+ },
455
+ );
335
456
  }
336
- lastState = setToolState(content.toolCallId, {
337
- ...createToolState({
338
- controller: toolCallController,
339
- streamToolCallId,
340
- }),
341
- hasResult: lastState.hasResult,
457
+ lastState = restartToolArgsStream({
458
+ toolCallId: content.toolCallId,
459
+ toolName: content.toolName,
460
+ state: lastState,
342
461
  });
343
462
  }
463
+ }
344
464
 
465
+ if (shouldWriteArgsText) {
345
466
  const argsTextDelta = content.argsText.slice(
346
467
  lastState.argsText.length,
347
468
  );
348
469
  lastState.controller.argsText.append(argsTextDelta);
349
470
 
350
- const shouldClose = isArgsTextComplete(content.argsText);
471
+ const shouldClose = shouldCloseArgsStream({
472
+ toolName: content.toolName,
473
+ argsText: content.argsText,
474
+ hasResult: content.result !== undefined,
475
+ });
351
476
  if (shouldClose) {
352
477
  lastState.controller.argsText.close();
353
478
  }
354
479
 
355
- patchToolState(content.toolCallId, lastState, {
480
+ lastState = patchToolState(content.toolCallId, lastState, {
356
481
  argsText: content.argsText,
357
482
  argsComplete: shouldClose,
358
483
  });
359
484
  }
360
485
  }
361
486
 
487
+ if (!lastState.argsComplete) {
488
+ const shouldClose = shouldCloseArgsStream({
489
+ toolName: content.toolName,
490
+ argsText: content.argsText,
491
+ hasResult: content.result !== undefined,
492
+ });
493
+ if (shouldClose) {
494
+ lastState.controller.argsText.close();
495
+ lastState = patchToolState(content.toolCallId, lastState, {
496
+ argsText: content.argsText,
497
+ argsComplete: true,
498
+ });
499
+ }
500
+ }
501
+
362
502
  if (content.result !== undefined && !lastState.hasResult) {
363
503
  patchToolState(content.toolCallId, lastState, {
364
504
  hasResult: true,
@@ -390,7 +530,7 @@ export function useToolInvocations({
390
530
  if (isInitialState.current) {
391
531
  isInitialState.current = false;
392
532
  }
393
- }, [state, controller]);
533
+ }, [state, controller, getTools]);
394
534
 
395
535
  const abort = (): Promise<void> => {
396
536
  humanInputRef.current.forEach(({ reject }) => {
@@ -414,6 +554,7 @@ export function useToolInvocations({
414
554
  reset: () => {
415
555
  isInitialState.current = true;
416
556
  void abort().finally(() => {
557
+ startedExecutionToolCallIdsRef.current.clear();
417
558
  toolCallIdAliasesRef.current.clear();
418
559
  ignoredResultToolCallIdsRef.current.clear();
419
560
  rewriteCounterRef.current = 0;
@@ -62,6 +62,13 @@ type Mutable<T> = {
62
62
  -readonly [P in keyof T]: T[P];
63
63
  };
64
64
 
65
+ const mergeInnerMessages = (existing: object, incoming: object) => ({
66
+ [symbolInnerMessage]: [
67
+ ...((existing as any)[symbolInnerMessage] ?? []),
68
+ ...((incoming as any)[symbolInnerMessage] ?? []),
69
+ ],
70
+ });
71
+
65
72
  const joinExternalMessages = (
66
73
  messages: readonly useExternalMessageConverter.Message[],
67
74
  ): ThreadMessageLike => {
@@ -77,6 +84,8 @@ const joinExternalMessages = (
77
84
  const toolCallIdx = assistantMessage.content.findIndex(
78
85
  (c) => c.type === "tool-call" && c.toolCallId === output.toolCallId,
79
86
  );
87
+ // Ignore orphaned tool results so one bad tool message does not
88
+ // prevent rendering the rest of the conversation.
80
89
  if (toolCallIdx !== -1) {
81
90
  const toolCall = assistantMessage.content[
82
91
  toolCallIdx
@@ -100,10 +109,6 @@ const joinExternalMessages = (
100
109
  isError: output.isError,
101
110
  messages: output.messages,
102
111
  };
103
- } else {
104
- throw new Error(
105
- `Tool call ${output.toolCallId} ${output.toolName} not found in assistant message`,
106
- );
107
112
  }
108
113
  } else {
109
114
  const role = output.role;
@@ -180,6 +185,24 @@ const joinExternalMessages = (
180
185
 
181
186
  // Add content parts, merging reasoning parts with same parentId
182
187
  for (const part of content) {
188
+ if (part.type === "tool-call") {
189
+ const existingIdx = assistantMessage.content.findIndex(
190
+ (c) =>
191
+ c.type === "tool-call" && c.toolCallId === part.toolCallId,
192
+ );
193
+ if (existingIdx !== -1) {
194
+ const existing = assistantMessage.content[
195
+ existingIdx
196
+ ] as typeof part;
197
+ assistantMessage.content[existingIdx] = {
198
+ ...existing,
199
+ ...part,
200
+ ...mergeInnerMessages(existing, part),
201
+ };
202
+ continue;
203
+ }
204
+ }
205
+
183
206
  if (
184
207
  part.type === "reasoning" &&
185
208
  "parentId" in part &&
@@ -198,12 +221,7 @@ const joinExternalMessages = (
198
221
  assistantMessage.content[existingIdx] = {
199
222
  ...existing,
200
223
  text: `${existing.text}\n\n${part.text}`,
201
- ...{
202
- [symbolInnerMessage]: [
203
- ...((existing as any)[symbolInnerMessage] ?? []),
204
- ...((part as any)[symbolInnerMessage] ?? []),
205
- ],
206
- },
224
+ ...mergeInnerMessages(existing, part),
207
225
  };
208
226
  continue;
209
227
  }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ export type ActionBarInteractionContextValue = {
6
+ acquireInteractionLock: () => () => void;
7
+ };
8
+
9
+ export const ActionBarInteractionContext =
10
+ createContext<ActionBarInteractionContextValue | null>(null);
11
+
12
+ export const useActionBarInteractionContext = () =>
13
+ useContext(ActionBarInteractionContext);
@@ -1,11 +1,19 @@
1
1
  "use client";
2
2
 
3
3
  import { Primitive } from "@radix-ui/react-primitive";
4
- import { type ComponentRef, forwardRef, ComponentPropsWithoutRef } from "react";
4
+ import {
5
+ type ComponentRef,
6
+ forwardRef,
7
+ ComponentPropsWithoutRef,
8
+ useCallback,
9
+ useMemo,
10
+ useState,
11
+ } from "react";
5
12
  import {
6
13
  useActionBarFloatStatus,
7
14
  HideAndFloatStatus,
8
15
  } from "./useActionBarFloatStatus";
16
+ import { ActionBarInteractionContext } from "./ActionBarInteractionContext";
9
17
 
10
18
  type PrimitiveDivProps = ComponentPropsWithoutRef<typeof Primitive.div>;
11
19
 
@@ -60,22 +68,44 @@ export const ActionBarPrimitiveRoot = forwardRef<
60
68
  ActionBarPrimitiveRoot.Element,
61
69
  ActionBarPrimitiveRoot.Props
62
70
  >(({ hideWhenRunning, autohide, autohideFloat, ...rest }, ref) => {
71
+ const [interactionCount, setInteractionCount] = useState(0);
72
+
73
+ const acquireInteractionLock = useCallback(() => {
74
+ let released = false;
75
+
76
+ setInteractionCount((count) => count + 1);
77
+
78
+ return () => {
79
+ if (released) return;
80
+ released = true;
81
+ setInteractionCount((count) => Math.max(0, count - 1));
82
+ };
83
+ }, []);
84
+
85
+ const interactionContext = useMemo(
86
+ () => ({ acquireInteractionLock }),
87
+ [acquireInteractionLock],
88
+ );
89
+
63
90
  const hideAndfloatStatus = useActionBarFloatStatus({
64
91
  hideWhenRunning,
65
92
  autohide,
66
93
  autohideFloat,
94
+ forceVisible: interactionCount > 0,
67
95
  });
68
96
 
69
97
  if (hideAndfloatStatus === HideAndFloatStatus.Hidden) return null;
70
98
 
71
99
  return (
72
- <Primitive.div
73
- {...(hideAndfloatStatus === HideAndFloatStatus.Floating
74
- ? { "data-floating": "true" }
75
- : null)}
76
- {...rest}
77
- ref={ref}
78
- />
100
+ <ActionBarInteractionContext.Provider value={interactionContext}>
101
+ <Primitive.div
102
+ {...(hideAndfloatStatus === HideAndFloatStatus.Floating
103
+ ? { "data-floating": "true" }
104
+ : null)}
105
+ {...rest}
106
+ ref={ref}
107
+ />
108
+ </ActionBarInteractionContext.Provider>
79
109
  );
80
110
  });
81
111
 
@@ -12,24 +12,27 @@ export type UseActionBarFloatStatusProps = {
12
12
  hideWhenRunning?: boolean | undefined;
13
13
  autohide?: "always" | "not-last" | "never" | undefined;
14
14
  autohideFloat?: "always" | "single-branch" | "never" | undefined;
15
+ forceVisible?: boolean | undefined;
15
16
  };
16
17
 
17
18
  export const useActionBarFloatStatus = ({
18
19
  hideWhenRunning,
19
20
  autohide,
20
21
  autohideFloat,
22
+ forceVisible,
21
23
  }: UseActionBarFloatStatusProps) => {
22
24
  return useAuiState((s) => {
23
25
  if (hideWhenRunning && s.thread.isRunning) return HideAndFloatStatus.Hidden;
24
26
 
25
27
  const autohideEnabled =
26
28
  autohide === "always" || (autohide === "not-last" && !s.message.isLast);
29
+ const isVisibleByInteraction = forceVisible || s.message.isHovering;
27
30
 
28
31
  // normal status
29
32
  if (!autohideEnabled) return HideAndFloatStatus.Normal;
30
33
 
31
34
  // hidden status
32
- if (!s.message.isHovering) return HideAndFloatStatus.Hidden;
35
+ if (!isVisibleByInteraction) return HideAndFloatStatus.Hidden;
33
36
 
34
37
  // floating status
35
38
  if (
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
- import { FC } from "react";
3
+ import { FC, useCallback, useEffect, useRef } from "react";
4
4
  import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
5
5
  import { ScopedProps, useDropdownMenuScope } from "./scope";
6
+ import { useActionBarInteractionContext } from "../actionBar/ActionBarInteractionContext";
6
7
 
7
8
  export namespace ActionBarMorePrimitiveRoot {
8
9
  export type Props = DropdownMenuPrimitive.DropdownMenuProps;
@@ -12,11 +13,60 @@ export const ActionBarMorePrimitiveRoot: FC<
12
13
  ActionBarMorePrimitiveRoot.Props
13
14
  > = ({
14
15
  __scopeActionBarMore,
16
+ open,
17
+ onOpenChange,
15
18
  ...rest
16
19
  }: ScopedProps<ActionBarMorePrimitiveRoot.Props>) => {
17
20
  const scope = useDropdownMenuScope(__scopeActionBarMore);
21
+ const actionBarInteraction = useActionBarInteractionContext();
22
+ const releaseInteractionLockRef = useRef<(() => void) | null>(null);
23
+ const isControlled = open !== undefined;
18
24
 
19
- return <DropdownMenuPrimitive.Root {...scope} {...rest} />;
25
+ const setInteractionOpen = useCallback(
26
+ (nextOpen: boolean) => {
27
+ if (nextOpen) {
28
+ if (releaseInteractionLockRef.current) return;
29
+ releaseInteractionLockRef.current =
30
+ actionBarInteraction?.acquireInteractionLock() ?? null;
31
+ return;
32
+ }
33
+
34
+ releaseInteractionLockRef.current?.();
35
+ releaseInteractionLockRef.current = null;
36
+ },
37
+ [actionBarInteraction],
38
+ );
39
+
40
+ const handleOpenChange = useCallback(
41
+ (nextOpen: boolean) => {
42
+ if (!isControlled) {
43
+ setInteractionOpen(nextOpen);
44
+ }
45
+ onOpenChange?.(nextOpen);
46
+ },
47
+ [isControlled, setInteractionOpen, onOpenChange],
48
+ );
49
+
50
+ useEffect(() => {
51
+ if (!isControlled) return;
52
+ setInteractionOpen(Boolean(open));
53
+ }, [isControlled, open, setInteractionOpen]);
54
+
55
+ useEffect(() => {
56
+ return () => {
57
+ releaseInteractionLockRef.current?.();
58
+ releaseInteractionLockRef.current = null;
59
+ };
60
+ }, []);
61
+
62
+ return (
63
+ <DropdownMenuPrimitive.Root
64
+ {...scope}
65
+ {...rest}
66
+ {...(open !== undefined ? { open } : null)}
67
+ onOpenChange={handleOpenChange}
68
+ />
69
+ );
20
70
  };
21
71
 
22
72
  ActionBarMorePrimitiveRoot.displayName = "ActionBarMorePrimitive.Root";
@@ -158,6 +158,86 @@ describe("convertExternalMessages", () => {
158
158
  expect(toolCallParts).toHaveLength(1);
159
159
  expect((toolCallParts[0] as any).result).toEqual({ data: "result" });
160
160
  });
161
+
162
+ it("should merge duplicate tool calls by toolCallId across assistant messages", () => {
163
+ const messages = [
164
+ {
165
+ id: "msg1",
166
+ role: "assistant" as const,
167
+ content: [
168
+ {
169
+ type: "tool-call" as const,
170
+ toolCallId: "tc1",
171
+ toolName: "search",
172
+ args: { query: "old" },
173
+ argsText: '{"query":"old"',
174
+ },
175
+ ],
176
+ },
177
+ {
178
+ id: "msg2",
179
+ role: "assistant" as const,
180
+ content: [
181
+ {
182
+ type: "tool-call" as const,
183
+ toolCallId: "tc1",
184
+ toolName: "search",
185
+ args: { query: "new" },
186
+ argsText: '{"query":"new"}',
187
+ },
188
+ ],
189
+ },
190
+ ];
191
+
192
+ const callback: useExternalMessageConverter.Callback<
193
+ (typeof messages)[number]
194
+ > = (msg) => msg;
195
+
196
+ const result = convertExternalMessages(messages, callback, false, {});
197
+
198
+ expect(result).toHaveLength(1);
199
+ expect(result[0]!.role).toBe("assistant");
200
+ const toolCallParts = result[0]!.content.filter(
201
+ (p) => p.type === "tool-call",
202
+ );
203
+ expect(toolCallParts).toHaveLength(1);
204
+ expect((toolCallParts[0] as any).args).toEqual({ query: "new" });
205
+ expect((toolCallParts[0] as any).argsText).toBe('{"query":"new"}');
206
+ });
207
+
208
+ it("should ignore orphaned tool results without throwing", () => {
209
+ const messages = [
210
+ {
211
+ id: "msg1",
212
+ role: "assistant" as const,
213
+ content: "First response",
214
+ },
215
+ {
216
+ role: "tool" as const,
217
+ toolCallId: "missing-tool-call",
218
+ toolName: "search",
219
+ result: { data: "orphan result" },
220
+ },
221
+ {
222
+ id: "msg2",
223
+ role: "assistant" as const,
224
+ content: "Second response",
225
+ },
226
+ ];
227
+
228
+ const callback: useExternalMessageConverter.Callback<
229
+ (typeof messages)[number]
230
+ > = (msg) => msg;
231
+
232
+ const result = convertExternalMessages(messages, callback, false, {});
233
+ expect(result).toHaveLength(1);
234
+ expect(result[0]!.role).toBe("assistant");
235
+
236
+ const textParts = result[0]!.content.filter((p) => p.type === "text");
237
+ expect(textParts).toHaveLength(2);
238
+ expect((textParts[0] as any).text).toBe("First response");
239
+ expect((textParts[1] as any).text).toBe("Second response");
240
+ });
161
241
  });
162
242
 
163
243
  describe("synthetic error message", () => {