@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.
- package/LICENSE +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +14 -10
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +25 -0
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +9 -0
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +4 -0
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +11 -0
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +6 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +26 -5
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +10 -7
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +43 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +16 -16
- package/src/runtime/adapter/run-aggregator.activity.test.ts +128 -0
- package/src/runtime/adapter/run-aggregator.ts +40 -0
- package/src/runtime/adapter/subscriber.test.ts +48 -0
- package/src/runtime/adapter/subscriber.ts +10 -0
- package/src/runtime/event-parser.test.ts +44 -0
- package/src/runtime/event-parser.ts +16 -0
- package/src/runtime/types.ts +7 -0
- package/src/useAgUiRuntime.fast-refresh.test.tsx +276 -0
- 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 },
|
package/src/runtime/types.ts
CHANGED
|
@@ -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));
|
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -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
|
|
188
|
+
queueController.notifyBusy();
|
|
177
189
|
} else {
|
|
178
|
-
queueController
|
|
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
|
|
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
|
-
}
|
|
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
|
-
|
|
343
|
+
useReplaySafeEffect(() => {
|
|
322
344
|
core.attachRuntime(runtime);
|
|
323
345
|
return () => {
|
|
324
346
|
core.detachRuntime();
|