@assistant-ui/react-ag-ui 0.0.62 → 0.0.64

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 (32) hide show
  1. package/LICENSE +1 -1
  2. package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
  3. package/dist/runtime/AgUiThreadRuntimeCore.js +14 -10
  4. package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
  5. package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
  6. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  7. package/dist/runtime/adapter/run-aggregator.js +25 -0
  8. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  9. package/dist/runtime/adapter/subscriber.d.ts +9 -0
  10. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  11. package/dist/runtime/adapter/subscriber.js +4 -0
  12. package/dist/runtime/adapter/subscriber.js.map +1 -1
  13. package/dist/runtime/event-parser.d.ts.map +1 -1
  14. package/dist/runtime/event-parser.js +11 -0
  15. package/dist/runtime/event-parser.js.map +1 -1
  16. package/dist/runtime/types.d.ts +6 -0
  17. package/dist/runtime/types.d.ts.map +1 -1
  18. package/dist/useAgUiRuntime.d.ts.map +1 -1
  19. package/dist/useAgUiRuntime.js +26 -5
  20. package/dist/useAgUiRuntime.js.map +1 -1
  21. package/package.json +10 -7
  22. package/src/runtime/AgUiThreadRuntimeCore.test.ts +43 -0
  23. package/src/runtime/AgUiThreadRuntimeCore.ts +16 -16
  24. package/src/runtime/adapter/run-aggregator.activity.test.ts +128 -0
  25. package/src/runtime/adapter/run-aggregator.ts +40 -0
  26. package/src/runtime/adapter/subscriber.test.ts +48 -0
  27. package/src/runtime/adapter/subscriber.ts +10 -0
  28. package/src/runtime/event-parser.test.ts +44 -0
  29. package/src/runtime/event-parser.ts +16 -0
  30. package/src/runtime/types.ts +7 -0
  31. package/src/useAgUiRuntime.fast-refresh.test.tsx +276 -0
  32. package/src/useAgUiRuntime.ts +27 -5
@@ -17,6 +17,7 @@ import {
17
17
  type A2uiState,
18
18
  type A2uiSurfaceState,
19
19
  } from "@assistant-ui/react-generative-ui/a2ui";
20
+ import jsonpatch, { type Operation } from "fast-json-patch";
20
21
  import { readMcpAppResourceUri } from "../mcp-tool-result";
21
22
  import { projectAgUiToolApprovals } from "./tool-approval";
22
23
  import type { AgUiEvent, AgUiInterrupt } from "../types";
@@ -631,12 +632,51 @@ export class RunAggregator {
631
632
  break;
632
633
  }
633
634
 
635
+ case "ACTIVITY_DELTA": {
636
+ this.handleActivityDelta(event);
637
+ break;
638
+ }
639
+
634
640
  default: {
635
641
  this.logger.debug?.("[agui] aggregator ignored event", event);
636
642
  }
637
643
  }
638
644
  }
639
645
 
646
+ private handleActivityDelta(
647
+ event: Extract<AgUiEvent, { type: "ACTIVITY_DELTA" }>,
648
+ ): void {
649
+ const scope = this.scopeOf(event);
650
+ const existing = this.activityParts.get(
651
+ this.partKey(scope, `message:${event.messageId}`),
652
+ );
653
+ if (!existing) {
654
+ this.logger.debug?.("[agui] activity delta has no snapshot", event);
655
+ return;
656
+ }
657
+ if (event.patch.length === 0) return;
658
+ try {
659
+ const result = jsonpatch.applyPatch(
660
+ existing.value,
661
+ event.patch as Operation[],
662
+ /* validateOperation */ true,
663
+ /* mutateDocument */ false,
664
+ );
665
+ if (!isPlainObject(result.newDocument)) {
666
+ this.logger.debug?.(
667
+ "[agui] activity delta produced non-object content",
668
+ event,
669
+ );
670
+ return;
671
+ }
672
+ existing.name = `agui-activity/${event.activityType}`;
673
+ existing.value = result.newDocument;
674
+ this.emit();
675
+ } catch (error) {
676
+ this.logger.error?.("[agui] failed to apply activity delta", error);
677
+ }
678
+ }
679
+
640
680
  private handleA2uiActivitySnapshot(
641
681
  event: Extract<AgUiEvent, { type: "ACTIVITY_SNAPSHOT" }>,
642
682
  ): void {
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createAgUiSubscriber } from "./subscriber";
3
+
4
+ const createSubscriber = () => {
5
+ const dispatch = vi.fn();
6
+ const logger = { debug: vi.fn(), error: vi.fn() };
7
+ const subscriber = createAgUiSubscriber({ dispatch, runId: "run-1", logger });
8
+ return { subscriber, dispatch, logger };
9
+ };
10
+
11
+ describe("createAgUiSubscriber", () => {
12
+ it("dispatches ACTIVITY_DELTA as a typed event", () => {
13
+ const { subscriber, dispatch } = createSubscriber();
14
+ subscriber.onActivityDeltaEvent?.({
15
+ event: {
16
+ type: "ACTIVITY_DELTA",
17
+ messageId: "act-1",
18
+ activityType: "progress",
19
+ patch: [{ op: "replace", path: "/step", value: 2 }],
20
+ },
21
+ });
22
+
23
+ expect(dispatch).toHaveBeenCalledWith({
24
+ type: "ACTIVITY_DELTA",
25
+ messageId: "act-1",
26
+ activityType: "progress",
27
+ patch: [{ op: "replace", path: "/step", value: 2 }],
28
+ });
29
+ });
30
+
31
+ it("logs step boundaries and dispatches nothing", () => {
32
+ const { subscriber, dispatch, logger } = createSubscriber();
33
+ const started = { type: "STEP_STARTED", stepName: "plan" };
34
+ const finished = { type: "STEP_FINISHED", stepName: "plan" };
35
+ subscriber.onStepStartedEvent?.({ event: started });
36
+ subscriber.onStepFinishedEvent?.({ event: finished });
37
+
38
+ expect(dispatch).not.toHaveBeenCalled();
39
+ expect(logger.debug).toHaveBeenCalledWith(
40
+ "[agui] step boundary ignored",
41
+ started,
42
+ );
43
+ expect(logger.debug).toHaveBeenCalledWith(
44
+ "[agui] step boundary ignored",
45
+ finished,
46
+ );
47
+ });
48
+ });
@@ -29,6 +29,7 @@ type Subscriber = {
29
29
  onToolCallChunkEvent?: (payload: { event: unknown }) => void;
30
30
  onToolCallResultEvent?: (payload: { event: unknown }) => void;
31
31
  onActivitySnapshotEvent?: (payload: { event: unknown }) => void;
32
+ onActivityDeltaEvent?: (payload: { event: unknown }) => void;
32
33
  onStateSnapshotEvent?: (payload: { event: unknown }) => void;
33
34
  onStateDeltaEvent?: (payload: { event: unknown }) => void;
34
35
  onMessagesSnapshotEvent?: (payload: { event: unknown }) => void;
@@ -39,6 +40,8 @@ type Subscriber = {
39
40
  onSubagentErrorEvent?: (payload: { event: unknown }) => void;
40
41
  onRunFinishedEvent?: (payload: { event: unknown }) => void;
41
42
  onRunErrorEvent?: (payload: { event: unknown }) => void;
43
+ onStepStartedEvent?: (payload: { event: unknown }) => void;
44
+ onStepFinishedEvent?: (payload: { event: unknown }) => void;
42
45
  onRunFinalized?: () => void;
43
46
  onRunFailed?: (payload: { error: Error }) => void;
44
47
  };
@@ -142,6 +145,8 @@ export const createAgUiSubscriber = (
142
145
  dispatchIfValid(event, "TOOL_CALL_RESULT"),
143
146
  onActivitySnapshotEvent: ({ event }) =>
144
147
  dispatchIfValid(event, "ACTIVITY_SNAPSHOT"),
148
+ onActivityDeltaEvent: ({ event }) =>
149
+ dispatchIfValid(event, "ACTIVITY_DELTA"),
145
150
  onStateSnapshotEvent: ({ event }) =>
146
151
  dispatchIfValid(event, "STATE_SNAPSHOT"),
147
152
  onStateDeltaEvent: ({ event }) => dispatchIfValid(event, "STATE_DELTA"),
@@ -174,11 +179,16 @@ export const createAgUiSubscriber = (
174
179
  );
175
180
  onRunFailed?.(error);
176
181
  },
182
+ onStepStartedEvent: ({ event }) =>
183
+ logger?.debug?.("[agui] step boundary ignored", event),
184
+ onStepFinishedEvent: ({ event }) =>
185
+ logger?.debug?.("[agui] step boundary ignored", event),
177
186
  onRunFinalized: () => {
178
187
  if (runFinishedDispatched) return;
179
188
  dispatch({ type: "RUN_FINISHED", runId });
180
189
  },
181
190
  onRunFailed: ({ error }) => {
191
+ if (runFinishedDispatched) return;
182
192
  runFinishedDispatched = true;
183
193
  onRunFailed?.(error);
184
194
  if (isAbortError(error)) {
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { parseAgUiEvent } from "./event-parser";
3
+
4
+ const delta = {
5
+ messageId: "act-1",
6
+ activityType: "progress",
7
+ patch: [{ op: "replace", path: "/step", value: 2 }],
8
+ };
9
+
10
+ describe("parseAgUiEvent ACTIVITY_DELTA", () => {
11
+ it("maps the client shape", () => {
12
+ expect(
13
+ parseAgUiEvent({
14
+ type: "ACTIVITY_DELTA",
15
+ ...delta,
16
+ subagentRunId: "sub",
17
+ }),
18
+ ).toEqual({ type: "ACTIVITY_DELTA", ...delta, subagentRunId: "sub" });
19
+ });
20
+
21
+ it("maps the bare wire shape without optional fields", () => {
22
+ expect(
23
+ parseAgUiEvent({ type: "ACTIVITY_DELTA", ...delta, timestamp: 1 }),
24
+ ).toEqual({ type: "ACTIVITY_DELTA", ...delta });
25
+ });
26
+
27
+ it("rejects a missing messageId", () => {
28
+ const logger = { debug: vi.fn(), error: vi.fn() };
29
+ const { messageId: _, ...rest } = delta;
30
+ expect(
31
+ parseAgUiEvent({ type: "ACTIVITY_DELTA", ...rest }, { logger }),
32
+ ).toBe(null);
33
+ expect(logger.debug).toHaveBeenCalledWith(
34
+ "[agui] ACTIVITY_DELTA missing messageId, activityType or patch array",
35
+ expect.objectContaining({ activityType: "progress" }),
36
+ );
37
+ });
38
+
39
+ it("rejects a patch that is not an array", () => {
40
+ expect(
41
+ parseAgUiEvent({ type: "ACTIVITY_DELTA", ...delta, patch: "nope" }),
42
+ ).toBe(null);
43
+ });
44
+ });
@@ -389,6 +389,22 @@ export const parseAgUiEvent = (
389
389
  },
390
390
  );
391
391
  }
392
+ case "ACTIVITY_DELTA": {
393
+ const messageId = getString("messageId");
394
+ const activityType = getString("activityType");
395
+ if (!messageId || !activityType || !Array.isArray(payload.patch)) {
396
+ return reject("missing messageId, activityType or patch array");
397
+ }
398
+ return withOptional(
399
+ {
400
+ type: "ACTIVITY_DELTA" as const,
401
+ messageId,
402
+ activityType,
403
+ patch: payload.patch as any[],
404
+ },
405
+ { subagentRunId: getString("subagentRunId") },
406
+ );
407
+ }
392
408
  case "RAW":
393
409
  return withOptional(
394
410
  { type: "RAW" as const, event: payload.event },
@@ -249,6 +249,13 @@ export type AgUiEvent =
249
249
  replace?: boolean;
250
250
  subagentRunId?: string;
251
251
  }
252
+ | {
253
+ type: "ACTIVITY_DELTA";
254
+ messageId: string;
255
+ activityType: string;
256
+ patch: any[];
257
+ subagentRunId?: string;
258
+ }
252
259
  | { type: "RAW"; event: any; source?: string }
253
260
  | { type: "CUSTOM"; name: string; value: any }
254
261
  | { type: "STATE_SNAPSHOT"; snapshot: any }
@@ -0,0 +1,276 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, Activity, version, type ComponentType } from "react";
4
+ import { afterAll, afterEach, expect, it, vi } from "vitest";
5
+ import type { HttpAgent } from "@ag-ui/client";
6
+ import type {
7
+ AssistantRuntime,
8
+ MessageQueueController,
9
+ } from "@assistant-ui/core";
10
+ import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
11
+ import type { UseAgUiRuntimeOptions } from "./runtime/types";
12
+ import { useAgUiRuntime, type AgUiAssistantRuntime } from "./useAgUiRuntime";
13
+
14
+ const onReact18 = version.startsWith("18.");
15
+
16
+ const base = vi.hoisted(() => ({ version: 0 }));
17
+ const queue = vi.hoisted(() => ({
18
+ controller: null as MessageQueueController | null,
19
+ busy: vi.fn(),
20
+ idle: vi.fn(),
21
+ }));
22
+ vi.mock("@assistant-ui/core", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("@assistant-ui/core")>();
24
+ return {
25
+ ...actual,
26
+ createMessageQueue: (
27
+ ...args: Parameters<typeof actual.createMessageQueue>
28
+ ) => {
29
+ const controller = actual.createMessageQueue(...args);
30
+ queue.controller = {
31
+ ...controller,
32
+ notifyBusy: () => {
33
+ queue.busy();
34
+ controller.notifyBusy();
35
+ },
36
+ notifyIdle: () => {
37
+ queue.idle();
38
+ controller.notifyIdle();
39
+ },
40
+ };
41
+ return queue.controller;
42
+ },
43
+ };
44
+ });
45
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
46
+ const actual =
47
+ await importOriginal<typeof import("@assistant-ui/core/react")>();
48
+ const derived = new WeakMap<AssistantRuntime, AssistantRuntime>();
49
+ return {
50
+ ...actual,
51
+ useExternalStoreRuntime: (
52
+ store: Parameters<typeof actual.useExternalStoreRuntime>[0],
53
+ ) => {
54
+ const runtime = actual.useExternalStoreRuntime(store);
55
+ if (base.version === 0) return runtime;
56
+ let next = derived.get(runtime);
57
+ if (!next) {
58
+ next = Object.create(runtime) as AssistantRuntime;
59
+ derived.set(runtime, next);
60
+ }
61
+ return next;
62
+ },
63
+ };
64
+ });
65
+
66
+ type Family = { current: unknown };
67
+ type RendererInternals = {
68
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
69
+ scheduleRefresh: (
70
+ root: unknown,
71
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
72
+ ) => void;
73
+ };
74
+
75
+ let renderer: RendererInternals | undefined;
76
+ const fiberRoots = new Set<unknown>();
77
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
78
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
79
+ supportsFiber: true,
80
+ inject: (internals: RendererInternals) => {
81
+ renderer = internals;
82
+ return 1;
83
+ },
84
+ onScheduleFiberRoot: () => {},
85
+ onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
86
+ onCommitFiberUnmount: () => {},
87
+ });
88
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
89
+
90
+ afterEach(() => {
91
+ cleanup();
92
+ fiberRoots.clear();
93
+ vi.restoreAllMocks();
94
+ base.version = 0;
95
+ queue.controller = null;
96
+ });
97
+ afterAll(() => vi.unstubAllGlobals());
98
+
99
+ let options: UseAgUiRuntimeOptions;
100
+ let runtime: AgUiAssistantRuntime;
101
+ const createProbe = () => {
102
+ const Before = () => {
103
+ runtime = useAgUiRuntime(options);
104
+ return null;
105
+ };
106
+ const After = () => {
107
+ runtime = useAgUiRuntime(options);
108
+ return null;
109
+ };
110
+ return { Before, After };
111
+ };
112
+
113
+ const refresh = async (Before: unknown, After: unknown) => {
114
+ const family: Family = { current: After };
115
+ renderer!.setRefreshHandler((type) =>
116
+ type === Before || type === After ? family : undefined,
117
+ );
118
+ await act(async () => {
119
+ for (const root of fiberRoots) {
120
+ renderer!.scheduleRefresh(root, {
121
+ staleFamilies: new Set(),
122
+ updatedFamilies: new Set([family]),
123
+ });
124
+ }
125
+ });
126
+ await act(async () => {});
127
+ };
128
+
129
+ it("keeps the wrapper across refresh and replaces it when the base runtime changes", async () => {
130
+ const agent = {
131
+ runAgent: vi.fn(),
132
+ abortRun: vi.fn(),
133
+ } as unknown as HttpAgent;
134
+ options = { agent };
135
+ const { Before, After } = createProbe();
136
+ const view = render(<Before />);
137
+ const wrapper = runtime;
138
+ await refresh(Before, After);
139
+ expect(runtime).toBe(wrapper);
140
+
141
+ base.version = 1;
142
+ view.rerender(<After />);
143
+ await act(async () => {});
144
+ expect(runtime).not.toBe(wrapper);
145
+ });
146
+
147
+ it("emits queue edges once across refresh and dispatches the queued message", async () => {
148
+ let finishFirst!: () => void;
149
+ const firstRun = new Promise<void>((resolve) => {
150
+ finishFirst = resolve;
151
+ });
152
+ const runAgent = vi.fn(
153
+ async (_input: unknown, subscriber: { onRunFinalized?: () => void }) => {
154
+ if (runAgent.mock.calls.length === 1) await firstRun;
155
+ subscriber.onRunFinalized?.();
156
+ },
157
+ );
158
+ options = {
159
+ agent: { runAgent, abortRun: vi.fn() } as unknown as HttpAgent,
160
+ unstable_enableMessageQueue: true,
161
+ };
162
+ const { Before, After } = createProbe();
163
+ const view = render(<Before />);
164
+ expect(queue.idle).toHaveBeenCalledTimes(1);
165
+
166
+ await act(async () => {
167
+ await runtime.thread.append({
168
+ role: "user",
169
+ content: [{ type: "text", text: "first" }],
170
+ });
171
+ });
172
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
173
+ expect(queue.busy).toHaveBeenCalledTimes(1);
174
+
175
+ await act(async () => {
176
+ await runtime.thread.append({
177
+ role: "user",
178
+ content: [{ type: "text", text: "second" }],
179
+ parentId: runtime.thread.getState().messages.at(-1)?.id ?? null,
180
+ });
181
+ });
182
+ expect(runAgent).toHaveBeenCalledTimes(1);
183
+ expect(
184
+ [
185
+ ...queue.controller!.adapter.items,
186
+ ...queue.controller!.adapter.steerItems,
187
+ ].map((item) => item.prompt),
188
+ ).toEqual(["second"]);
189
+
190
+ await refresh(Before, After);
191
+ expect(queue.busy).toHaveBeenCalledTimes(1);
192
+ expect(queue.idle).toHaveBeenCalledTimes(1);
193
+
194
+ await act(async () => {
195
+ finishFirst();
196
+ await firstRun;
197
+ });
198
+ await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(2));
199
+ await waitFor(() =>
200
+ expect([
201
+ ...queue.controller!.adapter.items,
202
+ ...queue.controller!.adapter.steerItems,
203
+ ]).toHaveLength(0),
204
+ );
205
+ await waitFor(() => expect(queue.busy).toHaveBeenCalledTimes(2));
206
+ await waitFor(() => expect(queue.idle).toHaveBeenCalledTimes(3));
207
+
208
+ await refresh(Before, After);
209
+ expect(queue.busy).toHaveBeenCalledTimes(2);
210
+ expect(queue.idle).toHaveBeenCalledTimes(3);
211
+
212
+ const firstController = queue.controller;
213
+ options = { ...options, unstable_enableMessageQueue: false };
214
+ view.rerender(<After />);
215
+ options = { ...options, unstable_enableMessageQueue: true };
216
+ view.rerender(<After />);
217
+ expect(queue.controller).not.toBe(firstController);
218
+ expect(queue.idle).toHaveBeenCalledTimes(4);
219
+ });
220
+
221
+ const expectInFlightRunAcrossRefresh = async (
222
+ exit: "hidden" | "unmount",
223
+ inActivity: boolean,
224
+ ) => {
225
+ let resolveRun!: () => void;
226
+ const run = new Promise<void>((resolve) => {
227
+ resolveRun = resolve;
228
+ });
229
+ const agent = {
230
+ runAgent: vi.fn(() => run),
231
+ abortRun: vi.fn(),
232
+ } as unknown as HttpAgent;
233
+ const attach = vi.spyOn(AgUiThreadRuntimeCore.prototype, "attachRuntime");
234
+ const detach = vi.spyOn(AgUiThreadRuntimeCore.prototype, "detachRuntime");
235
+ options = { agent };
236
+ const { Before, After } = createProbe();
237
+ const Shell: ComponentType<{ mode: "visible" | "hidden" }> = inActivity
238
+ ? ({ mode }) => (
239
+ <Activity mode={mode}>
240
+ <Before />
241
+ </Activity>
242
+ )
243
+ : () => <Before />;
244
+ const view = render(<Shell mode="visible" />);
245
+ const wrapper = runtime;
246
+ const core = attach.mock.instances[0]!;
247
+ act(() => {
248
+ void runtime.thread.append("hello");
249
+ });
250
+ await waitFor(() => expect(agent.runAgent).toHaveBeenCalledOnce());
251
+
252
+ await refresh(Before, After);
253
+ expect(agent.abortRun).not.toHaveBeenCalled();
254
+ expect(detach).not.toHaveBeenCalled();
255
+ expect(runtime).toBe(wrapper);
256
+ expect(attach.mock.instances.at(-1)).toBe(core);
257
+
258
+ if (exit === "hidden") view.rerender(<Shell mode="hidden" />);
259
+ else view.unmount();
260
+ await act(async () => {});
261
+ expect(detach).toHaveBeenCalledOnce();
262
+ expect(agent.abortRun).toHaveBeenCalledOnce();
263
+ resolveRun();
264
+ await act(async () => {
265
+ await run;
266
+ });
267
+ };
268
+
269
+ // Activity is React 19 only.
270
+ it.skipIf(onReact18).each(["hidden", "unmount"] as const)(
271
+ "keeps an in-flight run across refresh and detaches when %s",
272
+ (exit) => expectInFlightRunAcrossRefresh(exit, true),
273
+ );
274
+
275
+ it("keeps an in-flight run across refresh and detaches when unmounted outside an Activity", () =>
276
+ expectInFlightRunAcrossRefresh("unmount", false));
@@ -14,6 +14,7 @@ import {
14
14
  useExternalStoreSharedOptions,
15
15
  useRuntimeAdapters,
16
16
  } from "@assistant-ui/core/react";
17
+ import { useReplaySafeEffect } from "@assistant-ui/store/internal";
17
18
  import { createMessageQueue } from "@assistant-ui/core";
18
19
  import type {
19
20
  MessageQueueController,
@@ -113,6 +114,10 @@ export function useAgUiRuntime(
113
114
  // A dispatch whose count never moves was never observed as busy, so no
114
115
  // falling edge is coming to release the queue.
115
116
  const busyEdgesRef = useRef(0);
117
+ const lastQueueEdgeRef = useRef<{
118
+ controller: MessageQueueController;
119
+ busy: boolean;
120
+ } | null>(null);
116
121
  if (options.unstable_enableMessageQueue && !queueRef.current) {
117
122
  queueRef.current = createMessageQueue({
118
123
  run: (message) => {
@@ -171,11 +176,18 @@ export function useAgUiRuntime(
171
176
  // refusal would lose the message instead of keeping it visible.
172
177
  const queueBusy = isRunning || core.getPendingInterrupts() !== null;
173
178
  useEffect(() => {
179
+ if (!queueController) return;
180
+ if (
181
+ lastQueueEdgeRef.current?.controller === queueController &&
182
+ lastQueueEdgeRef.current.busy === queueBusy
183
+ )
184
+ return;
185
+ lastQueueEdgeRef.current = { controller: queueController, busy: queueBusy };
174
186
  if (queueBusy) {
175
187
  busyEdgesRef.current++;
176
- queueController?.notifyBusy();
188
+ queueController.notifyBusy();
177
189
  } else {
178
- queueController?.notifyIdle();
190
+ queueController.notifyIdle();
179
191
  }
180
192
  }, [queueBusy, queueController]);
181
193
 
@@ -309,16 +321,26 @@ export function useAgUiRuntime(
309
321
 
310
322
  const baseRuntime = useExternalStoreRuntime(store);
311
323
 
312
- const runtime = useMemo<AgUiAssistantRuntime>(() => {
324
+ const createRuntime = (): AgUiAssistantRuntime => {
313
325
  const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;
314
326
  wrapper.unstable_getPendingInterrupts = () =>
315
327
  core.getPendingInterrupts()?.interrupts ?? [];
316
328
  wrapper.unstable_submitInterruptResponses = (responses) =>
317
329
  core.submitInterruptResponses(responses);
318
330
  return wrapper;
319
- }, [baseRuntime, core]);
331
+ };
332
+ const [pinnedRuntime, setPinnedRuntime] = useState(() => ({
333
+ baseRuntime,
334
+ runtime: createRuntime(),
335
+ }));
336
+ let currentRuntime = pinnedRuntime;
337
+ if (pinnedRuntime.baseRuntime !== baseRuntime) {
338
+ currentRuntime = { baseRuntime, runtime: createRuntime() };
339
+ setPinnedRuntime(currentRuntime);
340
+ }
341
+ const runtime = currentRuntime.runtime;
320
342
 
321
- useEffect(() => {
343
+ useReplaySafeEffect(() => {
322
344
  core.attachRuntime(runtime);
323
345
  return () => {
324
346
  core.detachRuntime();