@assistant-ui/react-ag-ui 0.0.50 → 0.0.51

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.
@@ -1 +1 @@
1
- {"version":3,"file":"useAgUiRuntime.js","names":[],"sources":["../src/useAgUiRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n useExternalStoreRuntime,\n useExternalStoreSharedOptions,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport type { ToolExecutionStatus } from \"@assistant-ui/core\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport type { ReadonlyJSONValue } from \"assistant-stream/utils\";\nimport { makeLogger } from \"./runtime/logger\";\nimport type {\n AgUiInterrupt,\n AgUiResumeEntry,\n UseAgUiRuntimeOptions,\n} from \"./runtime/types\";\nimport { AgUiThreadRuntimeCore } from \"./runtime/AgUiThreadRuntimeCore\";\nimport { agUiExtras } from \"./agUiExtras\";\n\nconst EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];\n\nexport type AgUiAssistantRuntime = AssistantRuntime & {\n /**\n * @deprecated Use the `useAgUiInterrupts()` hook instead. This method is kept\n * for backward compatibility and will be removed in a future major release.\n */\n unstable_getPendingInterrupts: () => readonly AgUiInterrupt[];\n /**\n * @deprecated Use the `useAgUiSubmitInterruptResponses()` hook instead. This\n * method is kept for backward compatibility and will be removed in a future\n * major release.\n */\n unstable_submitInterruptResponses: (\n responses: readonly AgUiResumeEntry[],\n ) => Promise<void>;\n};\n\nexport function useAgUiRuntime(\n options: UseAgUiRuntimeOptions,\n): AgUiAssistantRuntime {\n const logger = useMemo(() => makeLogger(options.logger), [options.logger]);\n const [_version, setVersion] = useState(0);\n const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);\n const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);\n const runtimeAdapters = useRuntimeAdapters();\n\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n if (!coreRef.current) {\n coreRef.current = new AgUiThreadRuntimeCore({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n notifyUpdate,\n });\n }\n\n const core = coreRef.current;\n core.updateOptions({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n });\n\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (s) => s?.type === \"executing\",\n );\n\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread, ...rest } =\n threadListAdapter;\n\n return {\n ...rest,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n await onSwitchToNewThread();\n core.applyExternalMessages([]);\n core.resetState();\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n const result = await onSwitchToThread(threadId);\n core.applyExternalMessages(result.messages);\n if (result.state !== undefined) {\n core.loadExternalState(result.state);\n } else {\n core.resetState();\n }\n if (result.unstable_resume) {\n void core.resumeInFlightRun(result.messages);\n }\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\n const adapters = options.adapters;\n const adapterAdapters = useMemo(\n () => ({\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n threadList,\n }),\n [adapters, runtimeAdapters, threadList],\n );\n\n const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(\n () => {\n void _version; // rerender on version change\n\n return {\n ...shared,\n isLoading: core.isLoading,\n messageRepository: core.getMessageRepository(),\n state: core.getState(),\n isRunning: core.isRunning() || hasExecutingTools,\n extras: agUiExtras.provide({\n interrupts:\n core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,\n sendA2uiAction: (action) => core.sendA2uiAction(action),\n submitInterruptResponses: (responses) =>\n core.submitInterruptResponses(responses),\n steerAway: (message, responses) => core.steerAway(message, responses),\n state: core.getState(),\n setState: (next) => core.setState(next),\n }),\n unstable_enableToolInvocations: true,\n setToolStatuses,\n onNew: (message: AppendMessage) => core.append(message),\n onEdit: (message: AppendMessage) => core.edit(message),\n onReload: (parentId: string | null, config: { runConfig?: any }) =>\n core.reload(parentId, config),\n onCancel: async () => {\n core.cancel();\n },\n onAddToolResult: (options) => core.addToolResult(options),\n onResume: (config) => core.resume(config),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onLoadExternalState: (state: ReadonlyJSONValue) =>\n core.loadExternalState(state),\n adapters: adapterAdapters,\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n },\n // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate\n // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures\n [adapterAdapters, core, _version, hasExecutingTools, shared],\n );\n\n const baseRuntime = useExternalStoreRuntime(store);\n\n const runtime = useMemo<AgUiAssistantRuntime>(() => {\n const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;\n wrapper.unstable_getPendingInterrupts = () =>\n core.getPendingInterrupts()?.interrupts ?? [];\n wrapper.unstable_submitInterruptResponses = (responses) =>\n core.submitInterruptResponses(responses);\n return wrapper;\n }, [baseRuntime, core]);\n\n useEffect(() => {\n core.attachRuntime(runtime);\n return () => {\n core.detachRuntime();\n };\n }, [core, runtime]);\n\n useEffect(() => {\n core.__internal_load();\n }, [core]);\n\n return runtime;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"useAgUiRuntime.js","names":[],"sources":["../src/useAgUiRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n} from \"react\";\nimport {\n useExternalStoreRuntime,\n useExternalStoreSharedOptions,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport { createMessageQueue } from \"@assistant-ui/core\";\nimport type {\n MessageQueueController,\n ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport type {\n AssistantRuntime,\n AppendMessage,\n ExternalStoreAdapter,\n ThreadMessage,\n} from \"@assistant-ui/core\";\nimport type { ReadonlyJSONValue } from \"assistant-stream/utils\";\nimport { makeLogger } from \"./runtime/logger\";\nimport type {\n AgUiInterrupt,\n AgUiResumeEntry,\n UseAgUiRuntimeOptions,\n} from \"./runtime/types\";\nimport { AgUiThreadRuntimeCore } from \"./runtime/AgUiThreadRuntimeCore\";\nimport { agUiExtras } from \"./agUiExtras\";\nimport type { QueueItemState } from \"@assistant-ui/core/store\";\n\nconst EMPTY_INTERRUPTS: readonly AgUiInterrupt[] = [];\nconst EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = Object.freeze([]);\nconst subscribeNoop = () => () => {};\n\nexport type AgUiAssistantRuntime = AssistantRuntime & {\n /**\n * @deprecated Use the `useAgUiInterrupts()` hook instead. This method is kept\n * for backward compatibility and will be removed in a future major release.\n */\n unstable_getPendingInterrupts: () => readonly AgUiInterrupt[];\n /**\n * @deprecated Use the `useAgUiSubmitInterruptResponses()` hook instead. This\n * method is kept for backward compatibility and will be removed in a future\n * major release.\n */\n unstable_submitInterruptResponses: (\n responses: readonly AgUiResumeEntry[],\n ) => Promise<void>;\n};\n\nexport function useAgUiRuntime(\n options: UseAgUiRuntimeOptions,\n): AgUiAssistantRuntime {\n const logger = useMemo(() => makeLogger(options.logger), [options.logger]);\n const [_version, setVersion] = useState(0);\n const notifyUpdate = useCallback(() => setVersion((v) => v + 1), []);\n const coreRef = useRef<AgUiThreadRuntimeCore | null>(null);\n const runtimeAdapters = useRuntimeAdapters();\n\n const historyAdapter = options.adapters?.history ?? runtimeAdapters?.history;\n const threadListAdapter = options.adapters?.threadList;\n\n if (!coreRef.current) {\n coreRef.current = new AgUiThreadRuntimeCore({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n notifyUpdate,\n });\n }\n\n const core = coreRef.current;\n core.updateOptions({\n agent: options.agent,\n logger,\n showThinking: options.showThinking ?? true,\n autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,\n ...(options.onError && { onError: options.onError }),\n ...(options.onCancel && { onCancel: options.onCancel }),\n ...(historyAdapter && { history: historyAdapter }),\n });\n\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (s) => s?.type === \"executing\",\n );\n const isRunning = core.isRunning() || hasExecutingTools;\n\n // The driver sends through the agent core rather than the runtime, because\n // the runtime's append routes every tail append back into this queue.\n const queueRef = useRef<MessageQueueController | null>(null);\n // Counts rendered busy edges, whether a run start or an interrupt arriving.\n // A dispatch whose count never moves was never observed as busy, so no\n // falling edge is coming to release the queue.\n const busyEdgesRef = useRef(0);\n if (options.unstable_enableMessageQueue && !queueRef.current) {\n queueRef.current = createMessageQueue({\n run: (message) => {\n const controller = queueRef.current;\n const edgesAtDispatch = busyEdgesRef.current;\n // The queue drops the item before dispatching and stays busy until an\n // idle edge. An append observed as busy is released by that falling\n // edge, and releasing again here would advance the queue twice; one\n // that never runs (a pre-run rejection, or startRun false) has to be\n // released here or every later send buffers forever. An interrupt is\n // rechecked because a run that resolves without yielding to React\n // reaches here before the effect below has seen either edge.\n const releaseIfNoRun = () => {\n if (\n queueRef.current !== controller ||\n busyEdgesRef.current !== edgesAtDispatch ||\n core.getPendingInterrupts() !== null\n )\n return;\n controller?.notifyIdle();\n };\n void core.append(message).then(releaseIfNoRun, (e: unknown) => {\n releaseIfNoRun();\n logger.error?.(\"[agui] queued message failed to send\", e);\n });\n },\n });\n } else if (!options.unstable_enableMessageQueue && queueRef.current) {\n queueRef.current.clear();\n queueRef.current = null;\n }\n const queueController = options.unstable_enableMessageQueue\n ? queueRef.current\n : null;\n\n // Feeds the store memo below: the runtime core skips an adapter whose\n // identity is unchanged, so queue items have to move the store reference or\n // subscribers never see composer.queue change.\n const queueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.items ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n const steerQueueItems = useSyncExternalStore(\n queueController?.subscribe ?? subscribeNoop,\n () => queueController?.adapter.steerItems ?? EMPTY_QUEUE_ITEMS,\n () => EMPTY_QUEUE_ITEMS,\n );\n\n // Holds the queue while client-side tools are still executing, and while an\n // interrupt is unanswered: `append` refuses a new run until it is resolved,\n // and the queue drops an item before dispatching it, so draining into that\n // refusal would lose the message instead of keeping it visible.\n const queueBusy = isRunning || core.getPendingInterrupts() !== null;\n useEffect(() => {\n if (queueBusy) {\n busyEdgesRef.current++;\n queueController?.notifyBusy();\n } else {\n queueController?.notifyIdle();\n }\n }, [queueBusy, queueController]);\n\n const threadList = useMemo(() => {\n if (!threadListAdapter) return undefined;\n\n const { onSwitchToNewThread, onSwitchToThread, ...rest } =\n threadListAdapter;\n\n return {\n ...rest,\n onSwitchToNewThread: onSwitchToNewThread\n ? async () => {\n await onSwitchToNewThread();\n core.applyExternalMessages([]);\n core.resetState();\n }\n : undefined,\n onSwitchToThread: onSwitchToThread\n ? async (threadId: string) => {\n // Clear before the thread id flips, or the old messages leak\n // into the new thread as a sibling branch.\n core.applyExternalMessages([]);\n const result = await onSwitchToThread(threadId);\n core.applyExternalMessages(result.messages);\n if (result.state !== undefined) {\n core.loadExternalState(result.state);\n } else {\n core.resetState();\n }\n if (result.unstable_resume) {\n void core.resumeInFlightRun(result.messages);\n }\n }\n : undefined,\n };\n }, [threadListAdapter, core]);\n\n const adapters = options.adapters;\n const adapterAdapters = useMemo(\n () => ({\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n threadList,\n }),\n [adapters, runtimeAdapters, threadList],\n );\n\n const shared = useExternalStoreSharedOptions(options);\n const store = useMemo(\n () => {\n void _version; // rerender on version change\n\n return {\n ...shared,\n isLoading: core.isLoading,\n messageRepository: core.getMessageRepository(),\n state: core.getState(),\n isRunning,\n extras: agUiExtras.provide({\n interrupts:\n core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,\n sendA2uiAction: (action) => core.sendA2uiAction(action),\n submitInterruptResponses: (responses) =>\n core.submitInterruptResponses(responses),\n steerAway: (message, responses) => core.steerAway(message, responses),\n state: core.getState(),\n setState: (next) => core.setState(next),\n }),\n unstable_enableToolInvocations: true,\n setToolStatuses,\n onNew: (message: AppendMessage) => core.append(message),\n onEdit: (message: AppendMessage) => {\n queueController?.clear();\n return core.edit(message);\n },\n onReload: (parentId: string | null, config: { runConfig?: any }) => {\n queueController?.clear();\n return core.reload(parentId, config);\n },\n onCancel: async () => {\n queueController?.clear();\n core.cancel();\n },\n onAddToolResult: (options) => core.addToolResult(options),\n onResume: (config) => core.resume(config),\n setMessages: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onImport: (messages: readonly ThreadMessage[]) =>\n core.applyExternalMessages(messages),\n onLoadExternalState: (state: ReadonlyJSONValue) =>\n core.loadExternalState(state),\n adapters: adapterAdapters,\n ...(queueController && { queue: queueController.adapter }),\n } satisfies ExternalStoreAdapter<ThreadMessage>;\n },\n // _version is intentionally included to trigger re-computation when core state changes via notifyUpdate\n // toolInvocations intentionally excluded: abort/resume use refs internally and work with stale captures\n [\n adapterAdapters,\n core,\n _version,\n isRunning,\n queueController,\n queueItems,\n steerQueueItems,\n shared,\n ],\n );\n\n const baseRuntime = useExternalStoreRuntime(store);\n\n const runtime = useMemo<AgUiAssistantRuntime>(() => {\n const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;\n wrapper.unstable_getPendingInterrupts = () =>\n core.getPendingInterrupts()?.interrupts ?? [];\n wrapper.unstable_submitInterruptResponses = (responses) =>\n core.submitInterruptResponses(responses);\n return wrapper;\n }, [baseRuntime, core]);\n\n useEffect(() => {\n core.attachRuntime(runtime);\n return () => {\n core.detachRuntime();\n };\n }, [core, runtime]);\n\n useEffect(() => {\n core.__internal_load();\n }, [core]);\n\n return runtime;\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/react-ag-ui",
3
- "version": "0.0.50",
3
+ "version": "0.0.51",
4
4
  "description": "AG-UI protocol adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "ag-ui",
@@ -30,10 +30,10 @@
30
30
  "sideEffects": false,
31
31
  "dependencies": {
32
32
  "@ag-ui/client": "^0.0.57",
33
- "@assistant-ui/core": "^0.3.4",
34
- "@assistant-ui/react-generative-ui": "^0.0.11",
35
- "@assistant-ui/store": "^0.3.3",
36
- "assistant-stream": "^0.3.32",
33
+ "@assistant-ui/core": "^0.3.6",
34
+ "@assistant-ui/react-generative-ui": "^0.0.12",
35
+ "@assistant-ui/store": "^0.3.4",
36
+ "assistant-stream": "^0.3.34",
37
37
  "fast-json-patch": "^3.1.1"
38
38
  },
39
39
  "peerDependencies": {
@@ -46,11 +46,14 @@
46
46
  }
47
47
  },
48
48
  "devDependencies": {
49
+ "@testing-library/dom": "^10.4.1",
50
+ "@testing-library/react": "^16.3.2",
49
51
  "@types/react": "^19.2.17",
52
+ "jsdom": "^29.1.1",
50
53
  "react": "^19.2.8",
51
54
  "vitest": "^4.1.10",
52
55
  "zod": "^4.4.3",
53
- "@assistant-ui/x-buildutils": "0.0.20"
56
+ "@assistant-ui/x-buildutils": "0.0.21"
54
57
  },
55
58
  "publishConfig": {
56
59
  "access": "public",
@@ -90,6 +90,9 @@ export class AgUiThreadRuntimeCore {
90
90
  private exportedRepository: ExportedMessageRepository | undefined;
91
91
  private isRunningFlag = false;
92
92
  private abortController: AbortController | null = null;
93
+ // The agent that started the active run. updateOptions can swap this.agent
94
+ // mid-run, and cancelling has to reach the agent holding the live request.
95
+ private activeRunAgent: AbstractAgent | null = null;
93
96
  private stateSnapshot: ReadonlyJSONValue | undefined;
94
97
  private pendingError: Error | null = null;
95
98
  private history: ThreadHistoryAdapter | undefined;
@@ -333,7 +336,16 @@ export class AgUiThreadRuntimeCore {
333
336
 
334
337
  async cancel(): Promise<void> {
335
338
  if (!this.abortController) return;
336
- this.abortController.abort();
339
+ // Before the local abort, whose listener runs onCancel synchronously: a
340
+ // callback that starts another run replaces the agent's controller, and
341
+ // aborting afterwards would kill that replacement and leave this run live.
342
+ // The local abort is unconditional because abortRun is a user subclass's
343
+ // code, and a throw there would otherwise strand the thread as running.
344
+ try {
345
+ (this.activeRunAgent ?? this.agent).abortRun();
346
+ } finally {
347
+ this.abortController.abort();
348
+ }
337
349
  }
338
350
 
339
351
  async resume(config: ResumeRunConfig): Promise<void> {
@@ -978,6 +990,8 @@ export class AgUiThreadRuntimeCore {
978
990
  const abortController = new AbortController();
979
991
  const abortSignal = abortController.signal;
980
992
  this.abortController = abortController;
993
+ const runAgentInstance = this.agent;
994
+ this.activeRunAgent = runAgentInstance;
981
995
 
982
996
  let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
983
997
  abortSignal.addEventListener(
@@ -1021,18 +1035,22 @@ export class AgUiThreadRuntimeCore {
1021
1035
  runId,
1022
1036
  logger: this.logger,
1023
1037
  onRunFailed: (error) => {
1038
+ if (abortSignal.aborted) return;
1024
1039
  this.pendingError = error;
1025
1040
  this.onError?.(error);
1026
1041
  },
1027
1042
  });
1028
1043
  try {
1029
- (this.agent as any).messages = input.messages;
1030
- (this.agent as any).threadId = input.threadId;
1031
- (this.agent as any).state = input.state ?? null;
1044
+ (runAgentInstance as any).messages = input.messages;
1045
+ (runAgentInstance as any).threadId = input.threadId;
1046
+ (runAgentInstance as any).state = input.state ?? null;
1032
1047
  } catch {
1033
1048
  // ignore
1034
1049
  }
1035
- await (this.agent as any).runAgent(input, subscriber, {
1050
+ // HttpAgent ignores this third argument and is cancelled through
1051
+ // agent.abortRun(); it stays for subclasses that inherit the base
1052
+ // no-op abortRun and have no other cancellation hook.
1053
+ await (runAgentInstance as any).runAgent(input, subscriber, {
1036
1054
  signal: abortSignal,
1037
1055
  });
1038
1056
  }
@@ -1217,6 +1235,7 @@ export class AgUiThreadRuntimeCore {
1217
1235
  private finishRun(controller: AbortController | null) {
1218
1236
  if (this.abortController === controller) {
1219
1237
  this.abortController = null;
1238
+ this.activeRunAgent = null;
1220
1239
  }
1221
1240
  this.setRunning(false);
1222
1241
  }
@@ -71,6 +71,13 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
71
71
  * Defaults to `true`.
72
72
  */
73
73
  autoCancelPendingToolCalls?: boolean | undefined;
74
+ /**
75
+ * Buffer a message sent while a run is in flight and send it once the run
76
+ * settles, exposing it on `composer.queue` for `ComposerPrimitive.Queue`.
77
+ * The runtime owns the queue lifecycle because flushing needs the agent's
78
+ * send path and the run's own busy and idle edges.
79
+ */
80
+ unstable_enableMessageQueue?: boolean | undefined;
74
81
  onError?: (e: Error) => void;
75
82
  onCancel?: () => void;
76
83
  adapters?: UseAgUiRuntimeAdapters;
@@ -0,0 +1,352 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, it, expect, vi, afterEach } from "vitest";
4
+ import {
5
+ act,
6
+ cleanup,
7
+ render,
8
+ renderHook,
9
+ screen,
10
+ waitFor,
11
+ } from "@testing-library/react";
12
+ import type { AssistantRuntime } from "@assistant-ui/core";
13
+ import { AssistantRuntimeProvider } from "@assistant-ui/core/react";
14
+ import { useAuiState } from "@assistant-ui/store";
15
+ import type { HttpAgent } from "@ag-ui/client";
16
+ import { useAgUiRuntime } from "./useAgUiRuntime";
17
+
18
+ const gatedAgent = () => {
19
+ let release!: () => void;
20
+ const gate = new Promise<void>((resolve) => {
21
+ release = resolve;
22
+ });
23
+ const runAgent = vi.fn(
24
+ async (_input: unknown, subscriber: { onRunFinalized?: () => void }) => {
25
+ if (runAgent.mock.calls.length === 1) await gate;
26
+ subscriber.onRunFinalized?.();
27
+ },
28
+ );
29
+ return {
30
+ agent: { runAgent } as unknown as HttpAgent,
31
+ runAgent,
32
+ release: () => release(),
33
+ };
34
+ };
35
+
36
+ // Asserts through a subscribed consumer rather than the runtime object, which
37
+ // stays correct on read even when the store identity never moves.
38
+ const QueuedPrompts = () => {
39
+ const prompts = useAuiState((s) =>
40
+ s.composer.queue.map((item) => item.prompt).join(","),
41
+ );
42
+ return <div data-testid="queued">{prompts}</div>;
43
+ };
44
+
45
+ const mount = (runtime: AssistantRuntime) =>
46
+ render(
47
+ <AssistantRuntimeProvider runtime={runtime}>
48
+ <QueuedPrompts />
49
+ </AssistantRuntimeProvider>,
50
+ );
51
+
52
+ afterEach(() => {
53
+ cleanup();
54
+ });
55
+
56
+ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
57
+ it("buffers a send during a run and flushes it once the run settles", async () => {
58
+ const { agent, runAgent, release } = gatedAgent();
59
+
60
+ const { result } = renderHook(() =>
61
+ useAgUiRuntime({ agent, unstable_enableMessageQueue: true }),
62
+ );
63
+ mount(result.current);
64
+
65
+ await act(async () => {
66
+ await result.current.thread.append({
67
+ role: "user",
68
+ content: [{ type: "text", text: "first" }],
69
+ });
70
+ });
71
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
72
+ expect(result.current.thread.getState().capabilities.queue).toBe(true);
73
+
74
+ await act(async () => {
75
+ await result.current.thread.append({
76
+ role: "user",
77
+ content: [{ type: "text", text: "second" }],
78
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
79
+ });
80
+ });
81
+
82
+ // buffered rather than starting a second run, and visible to subscribers
83
+ expect(runAgent).toHaveBeenCalledTimes(1);
84
+ await waitFor(() =>
85
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
86
+ );
87
+
88
+ await act(async () => {
89
+ release();
90
+ });
91
+
92
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
93
+ await waitFor(() =>
94
+ expect(screen.getByTestId("queued").textContent).toBe(""),
95
+ );
96
+ });
97
+
98
+ it("keeps accepting sends after a queued run fails", async () => {
99
+ const runAgent = vi.fn(
100
+ async (_input: unknown, subscriber: { onRunFinalized?: () => void }) => {
101
+ if (runAgent.mock.calls.length === 1) throw new Error("boom");
102
+ subscriber.onRunFinalized?.();
103
+ },
104
+ );
105
+ const agent = { runAgent } as unknown as HttpAgent;
106
+
107
+ const { result } = renderHook(() =>
108
+ useAgUiRuntime({
109
+ agent,
110
+ unstable_enableMessageQueue: true,
111
+ onError: () => {},
112
+ }),
113
+ );
114
+ mount(result.current);
115
+
116
+ await act(async () => {
117
+ await result.current.thread.append({
118
+ role: "user",
119
+ content: [{ type: "text", text: "first" }],
120
+ });
121
+ });
122
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
123
+
124
+ // the failed run must not leave the queue permanently busy
125
+ await act(async () => {
126
+ await result.current.thread.append({
127
+ role: "user",
128
+ content: [{ type: "text", text: "second" }],
129
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
130
+ });
131
+ });
132
+
133
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
134
+ await waitFor(() =>
135
+ expect(screen.getByTestId("queued").textContent).toBe(""),
136
+ );
137
+ });
138
+
139
+ it("advances one item at a time when a queued dispatch fails", async () => {
140
+ const gate = (() => {
141
+ let release!: () => void;
142
+ const promise = new Promise<void>((r) => {
143
+ release = r;
144
+ });
145
+ return { promise, release: () => release() };
146
+ })();
147
+ // every run is held, so a second concurrent dispatch is observable as a
148
+ // call count rather than being hidden by sequential draining
149
+ const held: Array<() => void> = [];
150
+ const runAgent = vi.fn(async () => {
151
+ if (runAgent.mock.calls.length === 1) {
152
+ await gate.promise;
153
+ throw new Error("boom");
154
+ }
155
+ await new Promise<void>((resolve) => held.push(resolve));
156
+ });
157
+ const agent = { runAgent } as unknown as HttpAgent;
158
+
159
+ const { result } = renderHook(() =>
160
+ useAgUiRuntime({
161
+ agent,
162
+ unstable_enableMessageQueue: true,
163
+ onError: () => {},
164
+ }),
165
+ );
166
+ mount(result.current);
167
+
168
+ const send = async (text: string) => {
169
+ await act(async () => {
170
+ await result.current.thread.append({
171
+ role: "user",
172
+ content: [{ type: "text", text }],
173
+ parentId:
174
+ result.current.thread.getState().messages.at(-1)?.id ?? null,
175
+ });
176
+ });
177
+ };
178
+
179
+ await send("first");
180
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
181
+
182
+ // two behind the gated run, so a double release would drain both
183
+ await send("second");
184
+ await send("third");
185
+ await waitFor(() =>
186
+ expect(screen.getByTestId("queued").textContent).toBe("second,third"),
187
+ );
188
+
189
+ await act(async () => {
190
+ gate.release();
191
+ });
192
+
193
+ // exactly one item advances; a double release would have drained both
194
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
195
+ await waitFor(() =>
196
+ expect(screen.getByTestId("queued").textContent).toBe("third"),
197
+ );
198
+ expect(runAgent).toHaveBeenCalledTimes(2);
199
+ });
200
+
201
+ it("holds a queued message when the run settles on an interrupt", async () => {
202
+ const gate = (() => {
203
+ let release!: () => void;
204
+ const promise = new Promise<void>((r) => {
205
+ release = r;
206
+ });
207
+ return { promise, release: () => release() };
208
+ })();
209
+ const runAgent = vi.fn(
210
+ async (
211
+ _input: unknown,
212
+ subscriber: {
213
+ onRunFinishedEvent?: (e: unknown) => void;
214
+ onRunFinalized?: () => void;
215
+ },
216
+ ) => {
217
+ if (runAgent.mock.calls.length === 1) {
218
+ await gate.promise;
219
+ subscriber.onRunFinishedEvent?.({
220
+ event: {
221
+ type: "RUN_FINISHED",
222
+ runId: "run",
223
+ outcome: {
224
+ type: "interrupt",
225
+ interrupts: [{ id: "int-1", reason: "tool_call" }],
226
+ },
227
+ },
228
+ });
229
+ }
230
+ subscriber.onRunFinalized?.();
231
+ },
232
+ );
233
+ const agent = { runAgent } as unknown as HttpAgent;
234
+
235
+ const { result } = renderHook(() =>
236
+ useAgUiRuntime({ agent, unstable_enableMessageQueue: true }),
237
+ );
238
+ mount(result.current);
239
+
240
+ await act(async () => {
241
+ await result.current.thread.append({
242
+ role: "user",
243
+ content: [{ type: "text", text: "first" }],
244
+ });
245
+ });
246
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
247
+
248
+ await act(async () => {
249
+ await result.current.thread.append({
250
+ role: "user",
251
+ content: [{ type: "text", text: "second" }],
252
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
253
+ });
254
+ });
255
+ await waitFor(() =>
256
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
257
+ );
258
+
259
+ await act(async () => {
260
+ gate.release();
261
+ });
262
+
263
+ // append refuses a new run until the interrupt is answered, so the queued
264
+ // message must stay visible rather than being dropped into that refusal
265
+ await waitFor(() =>
266
+ expect(
267
+ result.current.thread.getState().messages.at(-1)?.status?.type,
268
+ ).toBe("requires-action"),
269
+ );
270
+ expect(runAgent).toHaveBeenCalledTimes(1);
271
+ expect(screen.getByTestId("queued").textContent).toBe("second");
272
+ });
273
+
274
+ it("does not wedge on an append that starts no run", async () => {
275
+ const { agent, runAgent } = gatedAgent();
276
+
277
+ const { result } = renderHook(() =>
278
+ useAgUiRuntime({ agent, unstable_enableMessageQueue: true }),
279
+ );
280
+ mount(result.current);
281
+
282
+ // startRun false never reaches the agent, so it produces no falling edge
283
+ await act(async () => {
284
+ await result.current.thread.append({
285
+ role: "user",
286
+ content: [{ type: "text", text: "staged" }],
287
+ startRun: false,
288
+ });
289
+ });
290
+ expect(runAgent).toHaveBeenCalledTimes(0);
291
+
292
+ await act(async () => {
293
+ await result.current.thread.append({
294
+ role: "user",
295
+ content: [{ type: "text", text: "real" }],
296
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
297
+ });
298
+ });
299
+
300
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
301
+ await waitFor(() =>
302
+ expect(screen.getByTestId("queued").textContent).toBe(""),
303
+ );
304
+ });
305
+
306
+ it("clears queued items when the flag is turned off", async () => {
307
+ const { agent, runAgent } = gatedAgent();
308
+
309
+ const { result, rerender } = renderHook(
310
+ ({ enabled }: { enabled: boolean }) =>
311
+ useAgUiRuntime({ agent, unstable_enableMessageQueue: enabled }),
312
+ { initialProps: { enabled: true } },
313
+ );
314
+ mount(result.current);
315
+
316
+ await act(async () => {
317
+ await result.current.thread.append({
318
+ role: "user",
319
+ content: [{ type: "text", text: "first" }],
320
+ });
321
+ });
322
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
323
+
324
+ await act(async () => {
325
+ await result.current.thread.append({
326
+ role: "user",
327
+ content: [{ type: "text", text: "second" }],
328
+ parentId: result.current.thread.getState().messages.at(-1)?.id ?? null,
329
+ });
330
+ });
331
+ await waitFor(() =>
332
+ expect(screen.getByTestId("queued").textContent).toBe("second"),
333
+ );
334
+
335
+ await act(async () => {
336
+ rerender({ enabled: false });
337
+ });
338
+
339
+ expect(result.current.thread.getState().capabilities.queue).toBe(false);
340
+ await waitFor(() =>
341
+ expect(screen.getByTestId("queued").textContent).toBe(""),
342
+ );
343
+ });
344
+
345
+ it("leaves the queue capability off when the flag is not set", () => {
346
+ const { agent } = gatedAgent();
347
+
348
+ const { result } = renderHook(() => useAgUiRuntime({ agent }));
349
+
350
+ expect(result.current.thread.getState().capabilities.queue).toBe(false);
351
+ });
352
+ });