@assistant-ui/react-langchain 0.0.31 → 0.0.33
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/dist/attachSubagentTranscripts.d.ts +19 -12
- package/dist/attachSubagentTranscripts.d.ts.map +1 -1
- package/dist/attachSubagentTranscripts.js +8 -4
- package/dist/attachSubagentTranscripts.js.map +1 -1
- package/dist/convertMessages.d.ts +8 -11
- package/dist/convertMessages.d.ts.map +1 -1
- package/dist/convertMessages.js +53 -15
- package/dist/convertMessages.js.map +1 -1
- package/dist/converter.d.ts +137 -138
- package/dist/converter.d.ts.map +1 -1
- package/dist/converter.js +2 -1
- package/dist/converter.js.map +1 -1
- package/dist/findForkCheckpointInHistory.d.ts +17 -19
- package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
- package/dist/hooks.d.ts +19 -22
- package/dist/hooks.d.ts.map +1 -1
- package/dist/index.d.ts +7 -8
- package/dist/index.d.ts.map +1 -0
- package/dist/resolveForkCheckpoint.d.ts +5 -7
- package/dist/resolveForkCheckpoint.d.ts.map +1 -1
- package/dist/runtimeExtras.d.ts +2 -5
- package/dist/runtimeExtras.d.ts.map +1 -1
- package/dist/sdkIdentity.d.ts +2 -5
- package/dist/sdkIdentity.d.ts.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/streamingTiming.d.ts +4 -7
- package/dist/streamingTiming.d.ts.map +1 -1
- package/dist/subagentMessagesProjection.d.ts +17 -0
- package/dist/subagentMessagesProjection.d.ts.map +1 -0
- package/dist/subagentMessagesProjection.js +40 -0
- package/dist/subagentMessagesProjection.js.map +1 -0
- package/dist/types.d.ts +106 -109
- package/dist/types.d.ts.map +1 -1
- package/dist/uiMessages.d.ts +37 -10
- package/dist/uiMessages.d.ts.map +1 -1
- package/dist/uiMessages.js +55 -5
- package/dist/uiMessages.js.map +1 -1
- package/dist/useStreamRuntime.d.ts +9 -12
- package/dist/useStreamRuntime.d.ts.map +1 -1
- package/dist/useStreamRuntime.js +102 -28
- package/dist/useStreamRuntime.js.map +1 -1
- package/dist/useSubagentTranscripts.d.ts +5 -8
- package/dist/useSubagentTranscripts.d.ts.map +1 -1
- package/dist/useSubagentTranscripts.js +129 -34
- package/dist/useSubagentTranscripts.js.map +1 -1
- package/package.json +16 -11
- package/src/attachSubagentTranscripts.test.ts +13 -5
- package/src/attachSubagentTranscripts.ts +26 -7
- package/src/convertMessages.test.ts +69 -0
- package/src/convertMessages.ts +46 -8
- package/src/converter.ts +6 -0
- package/src/subagentMessagesProjection.test.ts +175 -0
- package/src/subagentMessagesProjection.ts +50 -0
- package/src/uiMessages.test.ts +149 -0
- package/src/uiMessages.ts +79 -4
- package/src/useStreamRuntime.test.tsx +212 -0
- package/src/useStreamRuntime.ts +202 -50
- package/src/useStreamRuntime.voice.test.tsx +712 -0
- package/src/useSubagentTranscripts.test.tsx +795 -52
- package/src/useSubagentTranscripts.ts +269 -50
package/src/convertMessages.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
|
6
6
|
import {
|
|
7
7
|
convertLangChainContentBlock,
|
|
8
8
|
getCustomMetadata,
|
|
9
|
+
getMessageModality,
|
|
9
10
|
uiMessageToDataPart,
|
|
10
11
|
withAudioTranscript,
|
|
11
12
|
} from "./converter";
|
|
@@ -55,6 +56,35 @@ const contentBlocks = (content: unknown): readonly LangChainContentBlock[] => {
|
|
|
55
56
|
return [];
|
|
56
57
|
};
|
|
57
58
|
|
|
59
|
+
const normalizeToolCallArgs = (args: unknown): ReadonlyJSONObject => {
|
|
60
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
const prototype = Object.getPrototypeOf(args);
|
|
66
|
+
return prototype === Object.prototype || prototype === null
|
|
67
|
+
? (args as ReadonlyJSONObject)
|
|
68
|
+
: {};
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const toolCallArgs = (
|
|
75
|
+
value: unknown,
|
|
76
|
+
): { args: ReadonlyJSONObject; argsText: string } => {
|
|
77
|
+
const args = normalizeToolCallArgs(value);
|
|
78
|
+
try {
|
|
79
|
+
const argsText = JSON.stringify(args);
|
|
80
|
+
return typeof argsText === "string"
|
|
81
|
+
? { args, argsText }
|
|
82
|
+
: { args: {}, argsText: "{}" };
|
|
83
|
+
} catch {
|
|
84
|
+
return { args: {}, argsText: "{}" };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
58
88
|
const contentToParts = (content: unknown) => {
|
|
59
89
|
if (typeof content === "string")
|
|
60
90
|
return [{ type: "text" as const, text: content }];
|
|
@@ -89,25 +119,31 @@ export const convertLangChainBaseMessage = (
|
|
|
89
119
|
},
|
|
90
120
|
};
|
|
91
121
|
|
|
92
|
-
case "human":
|
|
122
|
+
case "human": {
|
|
123
|
+
const modality = getMessageModality(message.additional_kwargs);
|
|
93
124
|
return {
|
|
94
125
|
role: "user",
|
|
95
126
|
id: message.id,
|
|
96
127
|
content: contentToParts(message.content),
|
|
97
128
|
metadata: {
|
|
98
129
|
custom: getCustomMetadata(message.additional_kwargs),
|
|
130
|
+
...(modality && { modality }),
|
|
99
131
|
},
|
|
100
132
|
};
|
|
133
|
+
}
|
|
101
134
|
|
|
102
135
|
case "ai": {
|
|
103
136
|
const toolCallParts =
|
|
104
|
-
message.tool_calls?.map((tc) =>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
137
|
+
message.tool_calls?.map((tc) => {
|
|
138
|
+
const { args, argsText } = toolCallArgs(tc.args);
|
|
139
|
+
return {
|
|
140
|
+
type: "tool-call" as const,
|
|
141
|
+
toolCallId: tc.id,
|
|
142
|
+
toolName: tc.name,
|
|
143
|
+
args,
|
|
144
|
+
argsText,
|
|
145
|
+
};
|
|
146
|
+
}) ?? [];
|
|
111
147
|
|
|
112
148
|
const assistantStatus =
|
|
113
149
|
typeof message.status === "object" ? message.status : undefined;
|
|
@@ -120,6 +156,7 @@ export const convertLangChainBaseMessage = (
|
|
|
120
156
|
: undefined) ?? [];
|
|
121
157
|
|
|
122
158
|
const timing = metadata.messageTiming?.[message.id ?? ""];
|
|
159
|
+
const modality = getMessageModality(message.additional_kwargs);
|
|
123
160
|
|
|
124
161
|
return {
|
|
125
162
|
role: "assistant",
|
|
@@ -135,6 +172,7 @@ export const convertLangChainBaseMessage = (
|
|
|
135
172
|
metadata: {
|
|
136
173
|
custom: getCustomMetadata(message.additional_kwargs),
|
|
137
174
|
...(timing && { timing }),
|
|
175
|
+
...(modality && { modality }),
|
|
138
176
|
},
|
|
139
177
|
...(assistantStatus && { status: assistantStatus }),
|
|
140
178
|
};
|
package/src/converter.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AppendMessage,
|
|
3
3
|
DataMessagePart,
|
|
4
|
+
MessageModality,
|
|
4
5
|
ThreadAssistantMessage,
|
|
5
6
|
ThreadUserMessage,
|
|
6
7
|
} from "@assistant-ui/core";
|
|
@@ -146,6 +147,11 @@ export const getCustomMetadata = (
|
|
|
146
147
|
): Record<string, unknown> =>
|
|
147
148
|
(additionalKwargs?.metadata as Record<string, unknown>) ?? {};
|
|
148
149
|
|
|
150
|
+
export const getMessageModality = (
|
|
151
|
+
additionalKwargs: Record<string, unknown> | undefined,
|
|
152
|
+
): MessageModality | undefined =>
|
|
153
|
+
additionalKwargs?.modality === "voice" ? "voice" : undefined;
|
|
154
|
+
|
|
149
155
|
export const uiMessageToDataPart = <
|
|
150
156
|
TUIMessage extends { name: string; props: Record<string, unknown> },
|
|
151
157
|
>(
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { BaseMessage } from "@langchain/core/messages";
|
|
3
|
+
import {
|
|
4
|
+
matchesSubscription,
|
|
5
|
+
SubscriptionHandle,
|
|
6
|
+
} from "@langchain/langgraph-sdk/client";
|
|
7
|
+
import {
|
|
8
|
+
type Event,
|
|
9
|
+
messagesProjection,
|
|
10
|
+
type ProjectionSpec,
|
|
11
|
+
StreamStore,
|
|
12
|
+
} from "@langchain/langgraph-sdk/stream";
|
|
13
|
+
import { subagentMessagesProjection } from "./subagentMessagesProjection";
|
|
14
|
+
|
|
15
|
+
const PARENT = ["tools:parent"];
|
|
16
|
+
const CHILD = ["tools:parent", "tools:child"];
|
|
17
|
+
|
|
18
|
+
const human = (id: string, content: string) => ({
|
|
19
|
+
id,
|
|
20
|
+
type: "human",
|
|
21
|
+
content,
|
|
22
|
+
});
|
|
23
|
+
const ai = (id: string, content: string, tool_calls: unknown[] = []) => ({
|
|
24
|
+
id,
|
|
25
|
+
type: "ai",
|
|
26
|
+
content,
|
|
27
|
+
tool_calls,
|
|
28
|
+
});
|
|
29
|
+
const tool = (id: string, content: string, tool_call_id: string) => ({
|
|
30
|
+
id,
|
|
31
|
+
type: "tool",
|
|
32
|
+
content,
|
|
33
|
+
tool_call_id,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const values = (namespace: string[], messages: unknown[]) =>
|
|
37
|
+
({
|
|
38
|
+
method: "values",
|
|
39
|
+
params: { namespace, data: { messages } },
|
|
40
|
+
}) as unknown as Event;
|
|
41
|
+
|
|
42
|
+
const messagesEvent = (namespace: string[], data: Record<string, unknown>) =>
|
|
43
|
+
({
|
|
44
|
+
method: "messages",
|
|
45
|
+
params: { namespace, node: "model", data },
|
|
46
|
+
}) as unknown as Event;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Feeds the projection through the per-subscription matcher the SDK client
|
|
50
|
+
* applies before it hands an event to a subscription.
|
|
51
|
+
*/
|
|
52
|
+
const openProjection = (spec: ProjectionSpec<BaseMessage[]>) => {
|
|
53
|
+
let handle: SubscriptionHandle<Event> | undefined;
|
|
54
|
+
const unsubscribe = vi.fn(async () => {});
|
|
55
|
+
const subscribe = vi.fn(
|
|
56
|
+
async (params: SubscriptionHandle<Event>["params"]) => {
|
|
57
|
+
handle = new SubscriptionHandle<Event>(
|
|
58
|
+
"subscription",
|
|
59
|
+
params,
|
|
60
|
+
unsubscribe,
|
|
61
|
+
);
|
|
62
|
+
return handle;
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
const store = new StreamStore<BaseMessage[]>(spec.initial);
|
|
66
|
+
const runtime = spec.open({
|
|
67
|
+
thread: { subscribe } as never,
|
|
68
|
+
store,
|
|
69
|
+
rootBus: {
|
|
70
|
+
channels: [
|
|
71
|
+
"values",
|
|
72
|
+
"checkpoints",
|
|
73
|
+
"lifecycle",
|
|
74
|
+
"input",
|
|
75
|
+
"messages",
|
|
76
|
+
"tools",
|
|
77
|
+
],
|
|
78
|
+
subscribe: () => () => {},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
const push = (event: Event) => {
|
|
82
|
+
if (matchesSubscription(event, handle!.params)) handle!.push(event);
|
|
83
|
+
};
|
|
84
|
+
const ids = () => store.getSnapshot().map((message) => message.id);
|
|
85
|
+
return { subscribe, unsubscribe, runtime, push, ids };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const parentTurn = [
|
|
89
|
+
human("parent-human", "research"),
|
|
90
|
+
ai("parent-ai", "", [
|
|
91
|
+
{ id: "call-child", name: "task", args: { subagent_type: "worker" } },
|
|
92
|
+
]),
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const childRun = [
|
|
96
|
+
values(CHILD, [human("child-human", "sub task")]),
|
|
97
|
+
messagesEvent(CHILD, { event: "message-start", id: "child-ai", role: "ai" }),
|
|
98
|
+
messagesEvent(CHILD, {
|
|
99
|
+
event: "content-block-start",
|
|
100
|
+
index: 0,
|
|
101
|
+
content: { type: "text", text: "hello from the child" },
|
|
102
|
+
}),
|
|
103
|
+
messagesEvent(CHILD, { event: "message-finish" }),
|
|
104
|
+
values(CHILD, [
|
|
105
|
+
human("child-human", "sub task"),
|
|
106
|
+
ai("child-ai", "hello from the child"),
|
|
107
|
+
]),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
describe("subagentMessagesProjection", () => {
|
|
111
|
+
it("keeps the SDK projection's identity and pins its subscription to depth 0", async () => {
|
|
112
|
+
const spec = subagentMessagesProjection(PARENT);
|
|
113
|
+
expect(spec.namespace).toEqual(PARENT);
|
|
114
|
+
expect(spec.initial).toEqual([]);
|
|
115
|
+
expect(spec.key).not.toBe(messagesProjection(PARENT).key);
|
|
116
|
+
|
|
117
|
+
const { subscribe, runtime } = openProjection(spec);
|
|
118
|
+
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
|
|
119
|
+
expect(subscribe).toHaveBeenCalledWith({
|
|
120
|
+
channels: ["messages", "values"],
|
|
121
|
+
namespaces: [PARENT],
|
|
122
|
+
depth: 0,
|
|
123
|
+
});
|
|
124
|
+
await runtime.dispose();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("ignores a nested subagent's events while the parent's tool call runs", async () => {
|
|
128
|
+
const { subscribe, runtime, push, ids } = openProjection(
|
|
129
|
+
subagentMessagesProjection(PARENT),
|
|
130
|
+
);
|
|
131
|
+
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
|
|
132
|
+
|
|
133
|
+
push(values(PARENT, parentTurn));
|
|
134
|
+
await vi.waitFor(() =>
|
|
135
|
+
expect(ids()).toEqual(["parent-human", "parent-ai"]),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
for (const event of childRun) push(event);
|
|
139
|
+
push(
|
|
140
|
+
messagesEvent(PARENT, {
|
|
141
|
+
event: "message-start",
|
|
142
|
+
id: "parent-sentinel",
|
|
143
|
+
role: "ai",
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
push(messagesEvent(PARENT, { event: "message-finish" }));
|
|
147
|
+
await vi.waitFor(() => expect(ids()).toContain("parent-sentinel"));
|
|
148
|
+
expect(ids()).toEqual(["parent-human", "parent-ai", "parent-sentinel"]);
|
|
149
|
+
|
|
150
|
+
push(
|
|
151
|
+
values(PARENT, [
|
|
152
|
+
...parentTurn,
|
|
153
|
+
tool("parent-tool", "done", "call-child"),
|
|
154
|
+
]),
|
|
155
|
+
);
|
|
156
|
+
await vi.waitFor(() =>
|
|
157
|
+
expect(ids()).toEqual([
|
|
158
|
+
"parent-human",
|
|
159
|
+
"parent-ai",
|
|
160
|
+
"parent-tool",
|
|
161
|
+
"parent-sentinel",
|
|
162
|
+
]),
|
|
163
|
+
);
|
|
164
|
+
await runtime.dispose();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("unsubscribes the underlying subscription on dispose", async () => {
|
|
168
|
+
const { subscribe, unsubscribe, runtime } = openProjection(
|
|
169
|
+
subagentMessagesProjection(PARENT),
|
|
170
|
+
);
|
|
171
|
+
await vi.waitFor(() => expect(subscribe).toHaveBeenCalledOnce());
|
|
172
|
+
await runtime.dispose();
|
|
173
|
+
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce());
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { BaseMessage } from "@langchain/core/messages";
|
|
2
|
+
import {
|
|
3
|
+
messagesProjection,
|
|
4
|
+
type ProjectionSpec,
|
|
5
|
+
} from "@langchain/langgraph-sdk/stream";
|
|
6
|
+
|
|
7
|
+
type MessagesProjection = ProjectionSpec<BaseMessage[]>;
|
|
8
|
+
|
|
9
|
+
type ProjectionThread = Parameters<MessagesProjection["open"]>[0]["thread"];
|
|
10
|
+
|
|
11
|
+
const exactDepthThread = (thread: ProjectionThread): ProjectionThread =>
|
|
12
|
+
new Proxy(thread, {
|
|
13
|
+
get(target, property) {
|
|
14
|
+
if (property === "subscribe") {
|
|
15
|
+
return (...[params]: Parameters<ProjectionThread["subscribe"]>) =>
|
|
16
|
+
target.subscribe({ ...params, depth: 0 });
|
|
17
|
+
}
|
|
18
|
+
const value = Reflect.get(target, property, target);
|
|
19
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The SDK's messages projection subscribes at the protocol's default depth
|
|
25
|
+
* of 1 and applies every delivered event, so a nested subagent's `values`
|
|
26
|
+
* snapshots rebuild its parent's store from the child's state while the child
|
|
27
|
+
* runs. The projection exposes no depth option, so this spec opens it against
|
|
28
|
+
* a thread whose subscription is pinned to depth 0: the namespace the SDK
|
|
29
|
+
* sets stays and only events at exactly that namespace are delivered, the
|
|
30
|
+
* rule the SDK's root projection already applies. The client unions every
|
|
31
|
+
* subscription's depth into the server filter, so a depth 0 subscription
|
|
32
|
+
* narrows nothing for other consumers.
|
|
33
|
+
*/
|
|
34
|
+
export const subagentMessagesProjection = (
|
|
35
|
+
namespace: readonly string[],
|
|
36
|
+
): MessagesProjection => {
|
|
37
|
+
const projection = messagesProjection(namespace);
|
|
38
|
+
return {
|
|
39
|
+
key: `exact|${projection.key}`,
|
|
40
|
+
namespace: projection.namespace,
|
|
41
|
+
initial: projection.initial,
|
|
42
|
+
open({ thread, store, rootBus }) {
|
|
43
|
+
return projection.open({
|
|
44
|
+
thread: exactDepthThread(thread),
|
|
45
|
+
store,
|
|
46
|
+
rootBus,
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
};
|
package/src/uiMessages.test.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import {
|
|
3
3
|
applyUIUpdate,
|
|
4
|
+
createUIFoldMemo,
|
|
5
|
+
createUISnapshotMemo,
|
|
4
6
|
extractUIUpdate,
|
|
5
7
|
foldUIUpdates,
|
|
6
8
|
isUIUpdate,
|
|
7
9
|
mergeUIMessages,
|
|
10
|
+
reconcileUISnapshot,
|
|
8
11
|
} from "./uiMessages";
|
|
9
12
|
import type { UIMessage } from "./types";
|
|
10
13
|
|
|
@@ -137,6 +140,110 @@ describe("extractUIUpdate", () => {
|
|
|
137
140
|
});
|
|
138
141
|
});
|
|
139
142
|
|
|
143
|
+
describe("reconcileUISnapshot", () => {
|
|
144
|
+
const snapshot = (...entries: UIMessage[]) =>
|
|
145
|
+
entries.map((entry) => structuredClone(entry));
|
|
146
|
+
|
|
147
|
+
it("returns the previous list for an equal copy of the snapshot", () => {
|
|
148
|
+
const memo = createUISnapshotMemo();
|
|
149
|
+
const first = reconcileUISnapshot(
|
|
150
|
+
snapshot(ui("a", { x: 1 }), ui("b", { y: [1, 2] })),
|
|
151
|
+
memo,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const second = reconcileUISnapshot(
|
|
155
|
+
snapshot(ui("a", { x: 1 }), ui("b", { y: [1, 2] })),
|
|
156
|
+
memo,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(second).toBe(first);
|
|
160
|
+
expect(second).toEqual([ui("a", { x: 1 }), ui("b", { y: [1, 2] })]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("returns the same list for the same snapshot reference", () => {
|
|
164
|
+
const memo = createUISnapshotMemo();
|
|
165
|
+
const value = snapshot(ui("a"));
|
|
166
|
+
const first = reconcileUISnapshot(value, memo);
|
|
167
|
+
|
|
168
|
+
expect(reconcileUISnapshot(value, memo)).toBe(first);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("replaces only the entry that changed", () => {
|
|
172
|
+
const memo = createUISnapshotMemo();
|
|
173
|
+
const [a, b] = reconcileUISnapshot(
|
|
174
|
+
snapshot(ui("a", { x: 1 }), ui("b", { y: 1 })),
|
|
175
|
+
memo,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const result = reconcileUISnapshot(
|
|
179
|
+
snapshot(ui("a", { x: 1 }), ui("b", { y: 2 })),
|
|
180
|
+
memo,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(result[0]).toBe(a);
|
|
184
|
+
expect(result[1]).not.toBe(b);
|
|
185
|
+
expect(result[1]).toEqual(ui("b", { y: 2 }));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("keeps surviving entries across an addition and a removal", () => {
|
|
189
|
+
const memo = createUISnapshotMemo();
|
|
190
|
+
const [a, b] = reconcileUISnapshot(snapshot(ui("a"), ui("b")), memo);
|
|
191
|
+
|
|
192
|
+
const added = reconcileUISnapshot(
|
|
193
|
+
snapshot(ui("a"), ui("b"), ui("c")),
|
|
194
|
+
memo,
|
|
195
|
+
);
|
|
196
|
+
expect(added[0]).toBe(a);
|
|
197
|
+
expect(added[1]).toBe(b);
|
|
198
|
+
expect(added).toHaveLength(3);
|
|
199
|
+
|
|
200
|
+
const removed = reconcileUISnapshot(snapshot(ui("b"), ui("c")), memo);
|
|
201
|
+
expect(removed[0]).toBe(b);
|
|
202
|
+
expect(removed[1]).toBe(added[2]);
|
|
203
|
+
expect(removed).toHaveLength(2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns a new list when equal entries change position", () => {
|
|
207
|
+
const memo = createUISnapshotMemo();
|
|
208
|
+
const [a, b] = reconcileUISnapshot(snapshot(ui("a"), ui("b")), memo);
|
|
209
|
+
|
|
210
|
+
const result = reconcileUISnapshot(snapshot(ui("b"), ui("a")), memo);
|
|
211
|
+
|
|
212
|
+
expect(result).toEqual([ui("b"), ui("a")]);
|
|
213
|
+
expect(result[0]).toBe(b);
|
|
214
|
+
expect(result[1]).toBe(a);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("treats an entry with a changed name or metadata as new", () => {
|
|
218
|
+
const memo = createUISnapshotMemo();
|
|
219
|
+
const [a] = reconcileUISnapshot(
|
|
220
|
+
snapshot({ ...ui("a"), metadata: { message_id: "m1" } }),
|
|
221
|
+
memo,
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const renamed = reconcileUISnapshot(
|
|
225
|
+
snapshot({ ...ui("a"), name: "table", metadata: { message_id: "m1" } }),
|
|
226
|
+
memo,
|
|
227
|
+
);
|
|
228
|
+
expect(renamed[0]).not.toBe(a);
|
|
229
|
+
|
|
230
|
+
const moved = reconcileUISnapshot(
|
|
231
|
+
snapshot({ ...ui("a"), name: "table", metadata: { message_id: "m2" } }),
|
|
232
|
+
memo,
|
|
233
|
+
);
|
|
234
|
+
expect(moved[0]).not.toBe(renamed[0]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("returns an empty list for a non-array snapshot", () => {
|
|
238
|
+
const memo = createUISnapshotMemo();
|
|
239
|
+
expect(reconcileUISnapshot(undefined, memo)).toEqual([]);
|
|
240
|
+
reconcileUISnapshot(snapshot(ui("a")), memo);
|
|
241
|
+
|
|
242
|
+
expect(reconcileUISnapshot(null, memo)).toEqual([]);
|
|
243
|
+
expect(reconcileUISnapshot({ id: "a" }, memo)).toEqual([]);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
140
247
|
describe("mergeUIMessages", () => {
|
|
141
248
|
it("returns the snapshot when there are no live messages", () => {
|
|
142
249
|
expect(mergeUIMessages([], [ui("a")])).toEqual([ui("a")]);
|
|
@@ -187,4 +294,46 @@ describe("foldUIUpdates", () => {
|
|
|
187
294
|
it("returns an empty list for no events", () => {
|
|
188
295
|
expect(foldUIUpdates([])).toEqual([]);
|
|
189
296
|
});
|
|
297
|
+
|
|
298
|
+
const merge = (id: string, props: Record<string, unknown>) => ({
|
|
299
|
+
...ui(id, props),
|
|
300
|
+
metadata: { merge: true },
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("keeps the previous list when appended events carry no UI update", () => {
|
|
304
|
+
const memo = createUIFoldMemo();
|
|
305
|
+
const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
|
|
306
|
+
const folded = foldUIUpdates(events, memo);
|
|
307
|
+
|
|
308
|
+
expect(foldUIUpdates([...events, evt({ progress: 1 })], memo)).toBe(folded);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("keeps entries that appended updates do not touch", () => {
|
|
312
|
+
const memo = createUIFoldMemo();
|
|
313
|
+
const events = [evt(ui("a", { x: 1 })), evt(merge("a", { y: 2 }))];
|
|
314
|
+
const [merged] = foldUIUpdates(events, memo);
|
|
315
|
+
|
|
316
|
+
const result = foldUIUpdates([...events, evt(ui("b"))], memo);
|
|
317
|
+
|
|
318
|
+
expect(result).toEqual([merge("a", { x: 1, y: 2 }), ui("b")]);
|
|
319
|
+
expect(result[0]).toBe(merged);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("keeps folded entries when the buffer drops its oldest events", () => {
|
|
323
|
+
const memo = createUIFoldMemo();
|
|
324
|
+
const progress = evt({ progress: 1 });
|
|
325
|
+
foldUIUpdates([evt(ui("a", { x: 1 })), progress], memo);
|
|
326
|
+
|
|
327
|
+
expect(foldUIUpdates([progress, evt(merge("a", { y: 2 }))], memo)).toEqual([
|
|
328
|
+
merge("a", { x: 1, y: 2 }),
|
|
329
|
+
]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("folds from scratch when the buffer is replaced", () => {
|
|
333
|
+
const memo = createUIFoldMemo();
|
|
334
|
+
foldUIUpdates([evt(ui("a"))], memo);
|
|
335
|
+
|
|
336
|
+
expect(foldUIUpdates([evt(ui("b"))], memo)).toEqual([ui("b")]);
|
|
337
|
+
expect(foldUIUpdates([], memo)).toEqual([]);
|
|
338
|
+
});
|
|
190
339
|
});
|
package/src/uiMessages.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
+
import type { Channel } from "@langchain/react";
|
|
2
|
+
import { isJSONValueEqual } from "@assistant-ui/core/internal";
|
|
1
3
|
import type { RemoveUIMessage, UIMessage } from "./types";
|
|
2
4
|
|
|
5
|
+
/** Channels the generative-UI fold reads, at the root and per subagent. */
|
|
6
|
+
export const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
|
|
7
|
+
|
|
3
8
|
export type UIUpdate = UIMessage | RemoveUIMessage;
|
|
4
9
|
|
|
5
10
|
export const isUIUpdate = (
|
|
@@ -62,15 +67,85 @@ export const extractUIUpdate = (
|
|
|
62
67
|
return undefined;
|
|
63
68
|
};
|
|
64
69
|
|
|
65
|
-
export
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
export type UIFoldMemo = {
|
|
71
|
+
events: readonly unknown[];
|
|
72
|
+
messages: UIMessage[];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const createUIFoldMemo = (): UIFoldMemo => ({
|
|
76
|
+
events: [],
|
|
77
|
+
messages: [],
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Folds `custom`-channel events into UI messages, continuing the fold held in
|
|
82
|
+
* `memo`. The channel buffer holds each event object once, appends new events
|
|
83
|
+
* and drops its oldest ones once full, so only events after the previously
|
|
84
|
+
* folded last event are applied, and entries no later event touches keep
|
|
85
|
+
* their identity. A buffer that no longer contains that event was replaced
|
|
86
|
+
* and is folded from scratch.
|
|
87
|
+
*/
|
|
88
|
+
export const foldUIUpdates = (
|
|
89
|
+
events: readonly unknown[],
|
|
90
|
+
memo: UIFoldMemo = createUIFoldMemo(),
|
|
91
|
+
): UIMessage[] => {
|
|
92
|
+
const previous = memo.events;
|
|
93
|
+
const resumeAt =
|
|
94
|
+
previous.length === 0
|
|
95
|
+
? 0
|
|
96
|
+
: events.lastIndexOf(previous.at(-1), previous.length - 1) + 1;
|
|
97
|
+
let acc = previous.length > 0 && resumeAt === 0 ? [] : memo.messages;
|
|
98
|
+
for (let i = resumeAt; i < events.length; i++) {
|
|
99
|
+
const update = extractUIUpdate(events[i]);
|
|
69
100
|
if (update) acc = applyUIUpdate(acc, update);
|
|
70
101
|
}
|
|
102
|
+
memo.events = events;
|
|
103
|
+
memo.messages = acc;
|
|
71
104
|
return acc;
|
|
72
105
|
};
|
|
73
106
|
|
|
107
|
+
export type UISnapshotMemo = {
|
|
108
|
+
snapshot: unknown;
|
|
109
|
+
entries: readonly UIMessage[];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const createUISnapshotMemo = (): UISnapshotMemo => ({
|
|
113
|
+
snapshot: undefined,
|
|
114
|
+
entries: [],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Recovers entry identity across `values` events. The SDK rebuilds the
|
|
119
|
+
* `values` object from every snapshot and reconciles only the messages slot by
|
|
120
|
+
* id, so an unchanged UI list arrives as a new array of new objects on every
|
|
121
|
+
* superstep. An entry structurally equal to the previous entry with its id is
|
|
122
|
+
* replaced by that previous object, and a list whose entries all survive in
|
|
123
|
+
* place is replaced by the previous list.
|
|
124
|
+
*/
|
|
125
|
+
export const reconcileUISnapshot = (
|
|
126
|
+
snapshot: unknown,
|
|
127
|
+
memo: UISnapshotMemo,
|
|
128
|
+
): readonly UIMessage[] => {
|
|
129
|
+
if (snapshot === memo.snapshot) return memo.entries;
|
|
130
|
+
memo.snapshot = snapshot;
|
|
131
|
+
if (!Array.isArray(snapshot)) {
|
|
132
|
+
if (memo.entries.length > 0) memo.entries = [];
|
|
133
|
+
return memo.entries;
|
|
134
|
+
}
|
|
135
|
+
const previous = memo.entries;
|
|
136
|
+
const previousById = new Map(previous.map((ui) => [ui.id, ui]));
|
|
137
|
+
let same = snapshot.length === previous.length;
|
|
138
|
+
const entries = (snapshot as UIMessage[]).map((ui, index) => {
|
|
139
|
+
const before = previousById.get(ui.id);
|
|
140
|
+
const entry =
|
|
141
|
+
before !== undefined && isJSONValueEqual(before, ui) ? before : ui;
|
|
142
|
+
if (entry !== previous[index]) same = false;
|
|
143
|
+
return entry;
|
|
144
|
+
});
|
|
145
|
+
if (!same) memo.entries = entries;
|
|
146
|
+
return memo.entries;
|
|
147
|
+
};
|
|
148
|
+
|
|
74
149
|
/**
|
|
75
150
|
* Merges live-streamed UI with the state snapshot. The snapshot is
|
|
76
151
|
* authoritative by id: once a UI lands in graph state it supersedes its live
|