@assistant-ui/react-langchain 0.0.32 → 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 +13 -14
- package/dist/attachSubagentTranscripts.d.ts.map +1 -1
- package/dist/convertMessages.d.ts +6 -9
- 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 +128 -128
- 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 +15 -16
- package/dist/findForkCheckpointInHistory.d.ts.map +1 -1
- package/dist/hooks.d.ts +5 -7
- 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 +4 -5
- package/dist/resolveForkCheckpoint.d.ts.map +1 -1
- package/dist/runtimeExtras.d.ts +1 -3
- package/dist/runtimeExtras.d.ts.map +1 -1
- package/dist/sdkIdentity.d.ts +1 -3
- package/dist/sdkIdentity.d.ts.map +1 -1
- package/dist/sdkIdentity.js +1 -1
- package/dist/streamingTiming.d.ts +2 -4
- 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 +100 -103
- package/dist/types.d.ts.map +1 -1
- package/dist/uiMessages.d.ts +18 -6
- package/dist/uiMessages.d.ts.map +1 -1
- package/dist/uiMessages.js +33 -1
- package/dist/uiMessages.js.map +1 -1
- package/dist/useStreamRuntime.d.ts +7 -10
- package/dist/useStreamRuntime.d.ts.map +1 -1
- package/dist/useStreamRuntime.js +99 -25
- package/dist/useStreamRuntime.js.map +1 -1
- package/dist/useSubagentTranscripts.d.ts +3 -5
- package/dist/useSubagentTranscripts.d.ts.map +1 -1
- package/dist/useSubagentTranscripts.js +3 -2
- package/dist/useSubagentTranscripts.js.map +1 -1
- package/package.json +9 -5
- 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 +106 -0
- package/src/uiMessages.ts +43 -0
- package/src/useStreamRuntime.test.tsx +100 -0
- package/src/useStreamRuntime.ts +193 -44
- package/src/useStreamRuntime.voice.test.tsx +712 -0
- package/src/useSubagentTranscripts.ts +3 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@assistant-ui/react-langchain",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.33",
|
|
4
4
|
"description": "LangChain useStream adapter for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"langchain",
|
|
@@ -40,18 +40,22 @@
|
|
|
40
40
|
],
|
|
41
41
|
"sideEffects": false,
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@assistant-ui/core": "^0.3.
|
|
44
|
-
"@assistant-ui/store": "^0.3.
|
|
43
|
+
"@assistant-ui/core": "^0.3.21",
|
|
44
|
+
"@assistant-ui/store": "^0.3.15",
|
|
45
45
|
"assistant-cloud": "*",
|
|
46
|
-
"assistant-stream": "^0.3.
|
|
46
|
+
"assistant-stream": "^0.3.45"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
+
"@langchain/core": "^1.0.0",
|
|
49
50
|
"@langchain/langgraph-sdk": "^1.9.20",
|
|
50
51
|
"@langchain/react": "^1.0.20",
|
|
51
52
|
"@types/react": "*",
|
|
52
53
|
"react": "^18 || ^19"
|
|
53
54
|
},
|
|
54
55
|
"peerDependenciesMeta": {
|
|
56
|
+
"@langchain/core": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
55
59
|
"@langchain/langgraph-sdk": {
|
|
56
60
|
"optional": true
|
|
57
61
|
},
|
|
@@ -63,7 +67,7 @@
|
|
|
63
67
|
}
|
|
64
68
|
},
|
|
65
69
|
"devDependencies": {
|
|
66
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
70
|
+
"@assistant-ui/x-buildutils": "0.0.29",
|
|
67
71
|
"@langchain/core": "^1.2.11",
|
|
68
72
|
"@langchain/langgraph-sdk": "^1.11.0",
|
|
69
73
|
"@langchain/react": "^1.1.0",
|
|
@@ -27,6 +27,27 @@ const contentOf = (result: ReturnType<typeof convertLangChainBaseMessage>) => {
|
|
|
27
27
|
return result.content;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
describe("convertLangChainBaseMessage modality", () => {
|
|
31
|
+
it("lifts voice modality onto human and ai messages and ignores unknown values", () => {
|
|
32
|
+
for (const message of [humanMessage("Question"), aiMessage("Answer")]) {
|
|
33
|
+
const spoken = convertLangChainBaseMessage({
|
|
34
|
+
...message,
|
|
35
|
+
additional_kwargs: { modality: "voice" },
|
|
36
|
+
});
|
|
37
|
+
const unknown = convertLangChainBaseMessage({
|
|
38
|
+
...message,
|
|
39
|
+
additional_kwargs: { modality: "video" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(spoken).toHaveProperty("metadata", {
|
|
43
|
+
custom: {},
|
|
44
|
+
modality: "voice",
|
|
45
|
+
});
|
|
46
|
+
expect(unknown).toHaveProperty("metadata", { custom: {} });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
30
51
|
describe("convertLangChainBaseMessage file content parts", () => {
|
|
31
52
|
it("converts a base64 file block", () => {
|
|
32
53
|
const result = convertLangChainBaseMessage(
|
|
@@ -1113,6 +1134,54 @@ describe("convertLangChainBaseMessage malformed messages", () => {
|
|
|
1113
1134
|
]);
|
|
1114
1135
|
});
|
|
1115
1136
|
|
|
1137
|
+
it("normalizes missing, string, and array tool-call args to an object", () => {
|
|
1138
|
+
for (const args of [undefined, "not-json", ["x"], null]) {
|
|
1139
|
+
const result = convertLangChainBaseMessage(
|
|
1140
|
+
{
|
|
1141
|
+
...aiMessage([]),
|
|
1142
|
+
tool_calls: [{ id: "call-1", name: "lookup", args }],
|
|
1143
|
+
} as unknown as LangChainBaseMessage,
|
|
1144
|
+
{},
|
|
1145
|
+
);
|
|
1146
|
+
expect(contentOf(result)).toEqual([
|
|
1147
|
+
{
|
|
1148
|
+
type: "tool-call",
|
|
1149
|
+
toolCallId: "call-1",
|
|
1150
|
+
toolName: "lookup",
|
|
1151
|
+
args: {},
|
|
1152
|
+
argsText: "{}",
|
|
1153
|
+
},
|
|
1154
|
+
]);
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
it("normalizes unsafe object tool-call args to an empty object", () => {
|
|
1159
|
+
const cyclic: Record<string, unknown> = {};
|
|
1160
|
+
cyclic.self = cyclic;
|
|
1161
|
+
class CustomArgs {
|
|
1162
|
+
query = "x";
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
for (const args of [new Date(0), new Map(), new CustomArgs(), cyclic]) {
|
|
1166
|
+
const result = convertLangChainBaseMessage(
|
|
1167
|
+
{
|
|
1168
|
+
...aiMessage([]),
|
|
1169
|
+
tool_calls: [{ id: "call-1", name: "lookup", args }],
|
|
1170
|
+
} as unknown as LangChainBaseMessage,
|
|
1171
|
+
{},
|
|
1172
|
+
);
|
|
1173
|
+
expect(contentOf(result)).toEqual([
|
|
1174
|
+
{
|
|
1175
|
+
type: "tool-call",
|
|
1176
|
+
toolCallId: "call-1",
|
|
1177
|
+
toolName: "lookup",
|
|
1178
|
+
args: {},
|
|
1179
|
+
argsText: "{}",
|
|
1180
|
+
},
|
|
1181
|
+
]);
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1116
1185
|
it("converts a system message with null content to empty text", () => {
|
|
1117
1186
|
const result = convertLangChainBaseMessage(
|
|
1118
1187
|
{ _getType: () => "system", id: "msg-4", content: null },
|
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
|
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import {
|
|
3
3
|
applyUIUpdate,
|
|
4
4
|
createUIFoldMemo,
|
|
5
|
+
createUISnapshotMemo,
|
|
5
6
|
extractUIUpdate,
|
|
6
7
|
foldUIUpdates,
|
|
7
8
|
isUIUpdate,
|
|
8
9
|
mergeUIMessages,
|
|
10
|
+
reconcileUISnapshot,
|
|
9
11
|
} from "./uiMessages";
|
|
10
12
|
import type { UIMessage } from "./types";
|
|
11
13
|
|
|
@@ -138,6 +140,110 @@ describe("extractUIUpdate", () => {
|
|
|
138
140
|
});
|
|
139
141
|
});
|
|
140
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
|
+
|
|
141
247
|
describe("mergeUIMessages", () => {
|
|
142
248
|
it("returns the snapshot when there are no live messages", () => {
|
|
143
249
|
expect(mergeUIMessages([], [ui("a")])).toEqual([ui("a")]);
|
package/src/uiMessages.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Channel } from "@langchain/react";
|
|
2
|
+
import { isJSONValueEqual } from "@assistant-ui/core/internal";
|
|
2
3
|
import type { RemoveUIMessage, UIMessage } from "./types";
|
|
3
4
|
|
|
4
5
|
/** Channels the generative-UI fold reads, at the root and per subagent. */
|
|
@@ -103,6 +104,48 @@ export const foldUIUpdates = (
|
|
|
103
104
|
return acc;
|
|
104
105
|
};
|
|
105
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
|
+
|
|
106
149
|
/**
|
|
107
150
|
* Merges live-streamed UI with the state snapshot. The snapshot is
|
|
108
151
|
* authoritative by id: once a UI lands in graph state it supersedes its live
|