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

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 +0 -1
  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 +3 -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 +17 -3
  20. package/dist/useAgUiRuntime.js.map +1 -1
  21. package/package.json +8 -5
  22. package/src/runtime/AgUiThreadRuntimeCore.test.ts +43 -0
  23. package/src/runtime/AgUiThreadRuntimeCore.ts +0 -2
  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 +9 -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 +170 -0
  32. package/src/useAgUiRuntime.ts +14 -3
@@ -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,6 +179,10 @@ 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 });
@@ -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,170 @@
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 { AssistantRuntime } from "@assistant-ui/core";
7
+ import { AgUiThreadRuntimeCore } from "./runtime/AgUiThreadRuntimeCore";
8
+ import type { UseAgUiRuntimeOptions } from "./runtime/types";
9
+ import { useAgUiRuntime, type AgUiAssistantRuntime } from "./useAgUiRuntime";
10
+
11
+ const onReact18 = version.startsWith("18.");
12
+
13
+ const base = vi.hoisted(() => ({ version: 0 }));
14
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => {
15
+ const actual =
16
+ await importOriginal<typeof import("@assistant-ui/core/react")>();
17
+ const derived = new WeakMap<AssistantRuntime, AssistantRuntime>();
18
+ return {
19
+ ...actual,
20
+ useExternalStoreRuntime: (
21
+ store: Parameters<typeof actual.useExternalStoreRuntime>[0],
22
+ ) => {
23
+ const runtime = actual.useExternalStoreRuntime(store);
24
+ if (base.version === 0) return runtime;
25
+ let next = derived.get(runtime);
26
+ if (!next) {
27
+ next = Object.create(runtime) as AssistantRuntime;
28
+ derived.set(runtime, next);
29
+ }
30
+ return next;
31
+ },
32
+ };
33
+ });
34
+
35
+ type Family = { current: unknown };
36
+ type RendererInternals = {
37
+ setRefreshHandler: (resolve: (type: unknown) => Family | undefined) => void;
38
+ scheduleRefresh: (
39
+ root: unknown,
40
+ update: { staleFamilies: Set<Family>; updatedFamilies: Set<Family> },
41
+ ) => void;
42
+ };
43
+
44
+ let renderer: RendererInternals | undefined;
45
+ const fiberRoots = new Set<unknown>();
46
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
47
+ vi.stubGlobal("__REACT_DEVTOOLS_GLOBAL_HOOK__", {
48
+ supportsFiber: true,
49
+ inject: (internals: RendererInternals) => {
50
+ renderer = internals;
51
+ return 1;
52
+ },
53
+ onScheduleFiberRoot: () => {},
54
+ onCommitFiberRoot: (_id: number, root: unknown) => fiberRoots.add(root),
55
+ onCommitFiberUnmount: () => {},
56
+ });
57
+ const { cleanup, render, waitFor } = await import("@testing-library/react");
58
+
59
+ afterEach(() => {
60
+ cleanup();
61
+ fiberRoots.clear();
62
+ vi.restoreAllMocks();
63
+ base.version = 0;
64
+ });
65
+ afterAll(() => vi.unstubAllGlobals());
66
+
67
+ let options: UseAgUiRuntimeOptions;
68
+ let runtime: AgUiAssistantRuntime;
69
+ const createProbe = () => {
70
+ const Before = () => {
71
+ runtime = useAgUiRuntime(options);
72
+ return null;
73
+ };
74
+ const After = () => {
75
+ runtime = useAgUiRuntime(options);
76
+ return null;
77
+ };
78
+ return { Before, After };
79
+ };
80
+
81
+ const refresh = async (Before: unknown, After: unknown) => {
82
+ const family: Family = { current: After };
83
+ renderer!.setRefreshHandler((type) =>
84
+ type === Before || type === After ? family : undefined,
85
+ );
86
+ await act(async () => {
87
+ for (const root of fiberRoots) {
88
+ renderer!.scheduleRefresh(root, {
89
+ staleFamilies: new Set(),
90
+ updatedFamilies: new Set([family]),
91
+ });
92
+ }
93
+ });
94
+ await act(async () => {});
95
+ };
96
+
97
+ it("keeps the wrapper across refresh and replaces it when the base runtime changes", async () => {
98
+ const agent = {
99
+ runAgent: vi.fn(),
100
+ abortRun: vi.fn(),
101
+ } as unknown as HttpAgent;
102
+ options = { agent };
103
+ const { Before, After } = createProbe();
104
+ const view = render(<Before />);
105
+ const wrapper = runtime;
106
+ await refresh(Before, After);
107
+ expect(runtime).toBe(wrapper);
108
+
109
+ base.version = 1;
110
+ view.rerender(<After />);
111
+ await act(async () => {});
112
+ expect(runtime).not.toBe(wrapper);
113
+ });
114
+
115
+ const expectInFlightRunAcrossRefresh = async (
116
+ exit: "hidden" | "unmount",
117
+ inActivity: boolean,
118
+ ) => {
119
+ let resolveRun!: () => void;
120
+ const run = new Promise<void>((resolve) => {
121
+ resolveRun = resolve;
122
+ });
123
+ const agent = {
124
+ runAgent: vi.fn(() => run),
125
+ abortRun: vi.fn(),
126
+ } as unknown as HttpAgent;
127
+ const attach = vi.spyOn(AgUiThreadRuntimeCore.prototype, "attachRuntime");
128
+ const detach = vi.spyOn(AgUiThreadRuntimeCore.prototype, "detachRuntime");
129
+ options = { agent };
130
+ const { Before, After } = createProbe();
131
+ const Shell: ComponentType<{ mode: "visible" | "hidden" }> = inActivity
132
+ ? ({ mode }) => (
133
+ <Activity mode={mode}>
134
+ <Before />
135
+ </Activity>
136
+ )
137
+ : () => <Before />;
138
+ const view = render(<Shell mode="visible" />);
139
+ const wrapper = runtime;
140
+ const core = attach.mock.instances[0]!;
141
+ act(() => {
142
+ void runtime.thread.append("hello");
143
+ });
144
+ await waitFor(() => expect(agent.runAgent).toHaveBeenCalledOnce());
145
+
146
+ await refresh(Before, After);
147
+ expect(agent.abortRun).not.toHaveBeenCalled();
148
+ expect(detach).not.toHaveBeenCalled();
149
+ expect(runtime).toBe(wrapper);
150
+ expect(attach.mock.instances.at(-1)).toBe(core);
151
+
152
+ if (exit === "hidden") view.rerender(<Shell mode="hidden" />);
153
+ else view.unmount();
154
+ await act(async () => {});
155
+ expect(detach).toHaveBeenCalledOnce();
156
+ expect(agent.abortRun).toHaveBeenCalledOnce();
157
+ resolveRun();
158
+ await act(async () => {
159
+ await run;
160
+ });
161
+ };
162
+
163
+ // Activity is React 19 only.
164
+ it.skipIf(onReact18).each(["hidden", "unmount"] as const)(
165
+ "keeps an in-flight run across refresh and detaches when %s",
166
+ (exit) => expectInFlightRunAcrossRefresh(exit, true),
167
+ );
168
+
169
+ it("keeps an in-flight run across refresh and detaches when unmounted outside an Activity", () =>
170
+ 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,
@@ -309,16 +310,26 @@ export function useAgUiRuntime(
309
310
 
310
311
  const baseRuntime = useExternalStoreRuntime(store);
311
312
 
312
- const runtime = useMemo<AgUiAssistantRuntime>(() => {
313
+ const createRuntime = (): AgUiAssistantRuntime => {
313
314
  const wrapper = Object.create(baseRuntime) as AgUiAssistantRuntime;
314
315
  wrapper.unstable_getPendingInterrupts = () =>
315
316
  core.getPendingInterrupts()?.interrupts ?? [];
316
317
  wrapper.unstable_submitInterruptResponses = (responses) =>
317
318
  core.submitInterruptResponses(responses);
318
319
  return wrapper;
319
- }, [baseRuntime, core]);
320
+ };
321
+ const [pinnedRuntime, setPinnedRuntime] = useState(() => ({
322
+ baseRuntime,
323
+ runtime: createRuntime(),
324
+ }));
325
+ let currentRuntime = pinnedRuntime;
326
+ if (pinnedRuntime.baseRuntime !== baseRuntime) {
327
+ currentRuntime = { baseRuntime, runtime: createRuntime() };
328
+ setPinnedRuntime(currentRuntime);
329
+ }
330
+ const runtime = currentRuntime.runtime;
320
331
 
321
- useEffect(() => {
332
+ useReplaySafeEffect(() => {
322
333
  core.attachRuntime(runtime);
323
334
  return () => {
324
335
  core.detachRuntime();