@assistant-ui/react-langchain 0.0.34 → 0.0.36

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 (37) hide show
  1. package/dist/LangChainThreadController.d.ts +9 -0
  2. package/dist/LangChainThreadController.d.ts.map +1 -0
  3. package/dist/LangChainThreadController.js +21 -0
  4. package/dist/LangChainThreadController.js.map +1 -0
  5. package/dist/convertMessages.d.ts +2 -1
  6. package/dist/convertMessages.d.ts.map +1 -1
  7. package/dist/convertMessages.js +12 -12
  8. package/dist/convertMessages.js.map +1 -1
  9. package/dist/converter.d.ts +24 -1
  10. package/dist/converter.d.ts.map +1 -1
  11. package/dist/converter.js +84 -18
  12. package/dist/converter.js.map +1 -1
  13. package/dist/langChainThreadState.d.ts +54 -0
  14. package/dist/langChainThreadState.d.ts.map +1 -0
  15. package/dist/langChainThreadState.js +135 -0
  16. package/dist/langChainThreadState.js.map +1 -0
  17. package/dist/sdkIdentity.js +1 -1
  18. package/dist/types.d.ts +9 -0
  19. package/dist/types.d.ts.map +1 -1
  20. package/dist/useStreamRuntime.d.ts +3 -2
  21. package/dist/useStreamRuntime.d.ts.map +1 -1
  22. package/dist/useStreamRuntime.js +159 -144
  23. package/dist/useStreamRuntime.js.map +1 -1
  24. package/dist/useSubagentTranscripts.js +1 -1
  25. package/package.json +9 -8
  26. package/src/LangChainThreadController.ts +24 -0
  27. package/src/convertMessages.test.ts +23 -0
  28. package/src/convertMessages.ts +43 -17
  29. package/src/converter.test.ts +322 -0
  30. package/src/converter.ts +138 -27
  31. package/src/getPendingToolCalls.test.ts +35 -0
  32. package/src/langChainThreadState.test.ts +203 -0
  33. package/src/langChainThreadState.ts +239 -0
  34. package/src/types.ts +9 -0
  35. package/src/useStreamRuntime.cloud-options.test.tsx +61 -0
  36. package/src/useStreamRuntime.test.tsx +296 -8
  37. package/src/useStreamRuntime.ts +221 -249
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createLangChainThreadState,
4
+ reduceLangChainThreadState,
5
+ type StagedEntry,
6
+ type StagedMessage,
7
+ } from "./langChainThreadState";
8
+
9
+ const message = (
10
+ id: string,
11
+ type = "human",
12
+ content: unknown = id,
13
+ ): StagedMessage => ({ id, _getType: () => type, content });
14
+
15
+ const entry = (
16
+ stagedMessage: StagedMessage,
17
+ overrides: Partial<StagedEntry> = {},
18
+ ): StagedEntry => ({
19
+ message: stagedMessage,
20
+ runConfig: undefined,
21
+ reconcileOnEcho: false,
22
+ baseMessageCount: 0,
23
+ ...overrides,
24
+ });
25
+
26
+ describe("reduceLangChainThreadState", () => {
27
+ it("stages sends and transcripts without changing the previous snapshot", () => {
28
+ const base = message("base");
29
+ const send = message("send");
30
+ const transcript = message("transcript", "ai");
31
+ const initial = createLangChainThreadState();
32
+ const staged = reduceLangChainThreadState(initial, {
33
+ type: "stage",
34
+ entry: entry(send, { reconcileOnEcho: true, baseMessageCount: 1 }),
35
+ visibleMessages: [base],
36
+ });
37
+ const withTranscript = reduceLangChainThreadState(staged, {
38
+ type: "stage",
39
+ entry: entry(transcript, { transcriptStatus: "unsent" }),
40
+ visibleMessages: staged.visibleStagedMessages!,
41
+ });
42
+
43
+ expect(initial.stagedEntries.size).toBe(0);
44
+ expect(staged.visibleStagedMessages).toEqual([base, send]);
45
+ expect(staged.stagedEntries.has("transcript")).toBe(false);
46
+ expect(withTranscript.visibleStagedMessages).toEqual([
47
+ base,
48
+ send,
49
+ transcript,
50
+ ]);
51
+ expect(
52
+ withTranscript.stagedEntries.get("transcript")?.transcriptStatus,
53
+ ).toBe("unsent");
54
+ });
55
+
56
+ it("keeps an edit's truncated base while stream messages change", () => {
57
+ const base = message("base");
58
+ const edit = message("edit");
59
+ const staged = reduceLangChainThreadState(createLangChainThreadState(), {
60
+ type: "stageEdit",
61
+ entry: entry(edit),
62
+ baseMessages: [base],
63
+ });
64
+ const reconciled = reduceLangChainThreadState(staged, {
65
+ type: "reconcile",
66
+ messages: [base, message("later")],
67
+ visibleMessages: staged.visibleStagedMessages!,
68
+ });
69
+
70
+ expect(reconciled.stagedBaseMessages).toEqual([base]);
71
+ expect(reconciled.visibleStagedMessages).toEqual([base, edit]);
72
+ });
73
+
74
+ it("matches echoes by id or eligible human content after the base count", () => {
75
+ const base = message("base", "human", "same");
76
+ const first = message("first", "human", [{ type: "text", text: "same" }]);
77
+ const second = message("second", "human", "same");
78
+ const stagedOnce = reduceLangChainThreadState(
79
+ createLangChainThreadState(),
80
+ {
81
+ type: "stage",
82
+ entry: entry(first, { reconcileOnEcho: true, baseMessageCount: 1 }),
83
+ visibleMessages: [base],
84
+ },
85
+ );
86
+ const staged = reduceLangChainThreadState(stagedOnce, {
87
+ type: "stage",
88
+ entry: entry(second, { reconcileOnEcho: true, baseMessageCount: 1 }),
89
+ visibleMessages: stagedOnce.visibleStagedMessages!,
90
+ });
91
+ const rejected = reduceLangChainThreadState(staged, {
92
+ type: "reconcile",
93
+ messages: [base, message("assistant", "ai", "same")],
94
+ visibleMessages: staged.visibleStagedMessages!,
95
+ });
96
+ const matched = reduceLangChainThreadState(rejected, {
97
+ type: "reconcile",
98
+ messages: [base, message("echo", "human", "same"), second],
99
+ visibleMessages: rejected.visibleStagedMessages!,
100
+ });
101
+
102
+ expect(rejected.stagedEntries.size).toBe(2);
103
+ expect(matched.stagedEntries.size).toBe(0);
104
+ expect(matched.visibleStagedMessages).toBeNull();
105
+ expect(staged.stagedEntries.size).toBe(2);
106
+ });
107
+
108
+ it("returns the same state when a reconcile changes nothing", () => {
109
+ const staged = reduceLangChainThreadState(createLangChainThreadState(), {
110
+ type: "stage",
111
+ entry: entry(message("hidden")),
112
+ visibleMessages: [],
113
+ });
114
+ const settled = { ...staged, visibleStagedMessages: null };
115
+
116
+ expect(
117
+ reduceLangChainThreadState(settled, {
118
+ type: "reconcile",
119
+ messages: [message("base")],
120
+ visibleMessages: [message("base")],
121
+ }),
122
+ ).toBe(settled);
123
+ });
124
+
125
+ it("does not match content for entries that require an id echo", () => {
126
+ const stagedMessage = message("voice", "human", "spoken");
127
+ const staged = reduceLangChainThreadState(createLangChainThreadState(), {
128
+ type: "stage",
129
+ entry: entry(stagedMessage, { transcriptStatus: "unsent" }),
130
+ visibleMessages: [],
131
+ });
132
+ const noMatch = reduceLangChainThreadState(staged, {
133
+ type: "reconcile",
134
+ messages: [message("other", "human", "spoken")],
135
+ visibleMessages: staged.visibleStagedMessages!,
136
+ });
137
+ const matched = reduceLangChainThreadState(noMatch, {
138
+ type: "reconcile",
139
+ messages: [message("voice", "human", "different")],
140
+ visibleMessages: noMatch.visibleStagedMessages!,
141
+ });
142
+
143
+ expect(noMatch.stagedEntries.has("voice")).toBe(true);
144
+ expect(matched.stagedEntries.has("voice")).toBe(false);
145
+ });
146
+
147
+ it("reserves transcripts, restores them, and promotes only nontranscripts", () => {
148
+ const voice = message("voice");
149
+ const send = message("send");
150
+ const first = reduceLangChainThreadState(createLangChainThreadState(), {
151
+ type: "stage",
152
+ entry: entry(voice, { transcriptStatus: "unsent" }),
153
+ visibleMessages: [],
154
+ });
155
+ const staged = reduceLangChainThreadState(first, {
156
+ type: "stage",
157
+ entry: entry(send),
158
+ visibleMessages: first.visibleStagedMessages!,
159
+ });
160
+ const sent = reduceLangChainThreadState(staged, {
161
+ type: "markTranscript",
162
+ messages: [voice, send],
163
+ status: "sent",
164
+ });
165
+ const restored = reduceLangChainThreadState(sent, {
166
+ type: "markTranscript",
167
+ messages: [voice],
168
+ status: "unsent",
169
+ });
170
+ const promoted = reduceLangChainThreadState(restored, {
171
+ type: "promote",
172
+ messages: [voice, send],
173
+ visibleMessages: restored.visibleStagedMessages!,
174
+ });
175
+
176
+ expect(staged.stagedEntries.get("voice")?.transcriptStatus).toBe("unsent");
177
+ expect(sent.stagedEntries.get("voice")?.transcriptStatus).toBe("sent");
178
+ expect(sent.stagedEntries.get("send")?.transcriptStatus).toBeUndefined();
179
+ expect(promoted.stagedEntries.has("send")).toBe(false);
180
+ expect(promoted.stagedEntries.get("voice")?.transcriptStatus).toBe(
181
+ "unsent",
182
+ );
183
+ expect(promoted.visibleStagedMessages).toEqual([voice]);
184
+ });
185
+
186
+ it("removes a failed stage and clears the edit base", () => {
187
+ const stagedMessage = message("failed");
188
+ const staged = reduceLangChainThreadState(createLangChainThreadState(), {
189
+ type: "stageEdit",
190
+ entry: entry(stagedMessage),
191
+ baseMessages: [message("base")],
192
+ });
193
+ const removed = reduceLangChainThreadState(staged, {
194
+ type: "remove",
195
+ id: "failed",
196
+ visibleMessages: staged.visibleStagedMessages!,
197
+ });
198
+
199
+ expect(removed.stagedEntries.size).toBe(0);
200
+ expect(removed.stagedBaseMessages).toBeNull();
201
+ expect(removed.visibleStagedMessages).toBeNull();
202
+ });
203
+ });
@@ -0,0 +1,239 @@
1
+ import type { AppendMessage } from "@assistant-ui/core";
2
+ import type { LangChainBaseMessage } from "./types";
3
+ import { getMessageType } from "./convertMessages";
4
+
5
+ export type StagedMessage = LangChainBaseMessage & { id: string };
6
+
7
+ export type StagedEntry = {
8
+ message: StagedMessage;
9
+ runConfig: AppendMessage["runConfig"];
10
+ reconcileOnEcho: boolean;
11
+ baseMessageCount: number;
12
+ transcriptStatus?: "unsent" | "sent";
13
+ };
14
+
15
+ export type LangChainThreadState = {
16
+ forkGeneration: number;
17
+ forkPending: boolean;
18
+ stagedEntries: ReadonlyMap<string, StagedEntry>;
19
+ stagedBaseMessages: LangChainBaseMessage[] | null;
20
+ visibleStagedMessages: LangChainBaseMessage[] | null;
21
+ };
22
+
23
+ export type LangChainThreadAction =
24
+ | { type: "supersedeFork" }
25
+ | { type: "finishFork"; generation: number }
26
+ | { type: "cancelFork" }
27
+ | {
28
+ type: "stage";
29
+ entry: StagedEntry;
30
+ visibleMessages: readonly LangChainBaseMessage[];
31
+ }
32
+ | {
33
+ type: "stageEdit";
34
+ entry: StagedEntry;
35
+ baseMessages: LangChainBaseMessage[];
36
+ }
37
+ | {
38
+ type: "reconcile";
39
+ messages: LangChainBaseMessage[];
40
+ visibleMessages: readonly LangChainBaseMessage[];
41
+ }
42
+ | {
43
+ type: "markTranscript";
44
+ messages: readonly LangChainBaseMessage[];
45
+ status: "unsent" | "sent";
46
+ }
47
+ | {
48
+ type: "remove";
49
+ id: string;
50
+ visibleMessages: readonly LangChainBaseMessage[];
51
+ }
52
+ | {
53
+ type: "promote";
54
+ messages: readonly LangChainBaseMessage[];
55
+ visibleMessages: readonly LangChainBaseMessage[];
56
+ };
57
+
58
+ export const createLangChainThreadState = (): LangChainThreadState => ({
59
+ forkGeneration: 0,
60
+ forkPending: false,
61
+ stagedEntries: new Map(),
62
+ stagedBaseMessages: null,
63
+ visibleStagedMessages: null,
64
+ });
65
+
66
+ const humanContentText = (content: LangChainBaseMessage["content"]) => {
67
+ if (typeof content === "string") return content;
68
+ if (!Array.isArray(content)) return "";
69
+ return content
70
+ .filter(
71
+ (part): part is { type: "text"; text: string } =>
72
+ typeof part === "object" &&
73
+ part !== null &&
74
+ part.type === "text" &&
75
+ typeof part.text === "string",
76
+ )
77
+ .map((part) => part.text)
78
+ .join("");
79
+ };
80
+
81
+ const hasSameMessageContent = (
82
+ a: LangChainBaseMessage,
83
+ b: LangChainBaseMessage,
84
+ ) => humanContentText(a.content) === humanContentText(b.content);
85
+
86
+ export const reduceLangChainThreadState = (
87
+ state: LangChainThreadState,
88
+ action: LangChainThreadAction,
89
+ ): LangChainThreadState => {
90
+ switch (action.type) {
91
+ case "supersedeFork":
92
+ return {
93
+ ...state,
94
+ forkGeneration: state.forkGeneration + 1,
95
+ forkPending: true,
96
+ };
97
+ case "finishFork":
98
+ return state.forkGeneration === action.generation
99
+ ? { ...state, forkPending: false }
100
+ : state;
101
+ case "cancelFork":
102
+ return {
103
+ ...state,
104
+ forkGeneration: state.forkGeneration + 1,
105
+ forkPending: false,
106
+ };
107
+ case "stage": {
108
+ const stagedEntries = new Map(state.stagedEntries);
109
+ stagedEntries.set(action.entry.message.id, action.entry);
110
+ return {
111
+ ...state,
112
+ stagedEntries,
113
+ visibleStagedMessages: [
114
+ ...action.visibleMessages,
115
+ action.entry.message,
116
+ ],
117
+ };
118
+ }
119
+ case "stageEdit": {
120
+ const stagedEntries = new Map(state.stagedEntries);
121
+ stagedEntries.set(action.entry.message.id, action.entry);
122
+ return {
123
+ ...state,
124
+ stagedEntries,
125
+ stagedBaseMessages: action.baseMessages,
126
+ visibleStagedMessages: [...action.baseMessages, action.entry.message],
127
+ };
128
+ }
129
+ case "reconcile": {
130
+ if (state.stagedEntries.size === 0) return state;
131
+ // Staged edits must keep their truncated base while stream updates arrive before promotion.
132
+ const baseMessages = state.stagedBaseMessages ?? action.messages;
133
+ const stagedEntries = new Map(state.stagedEntries);
134
+ const remainingStagedMessages: LangChainBaseMessage[] = [];
135
+ const matchedBaseMessageIndexes = new Set<number>();
136
+ const visibleStagedIds = new Set(
137
+ action.visibleMessages.flatMap((message) =>
138
+ message.id ? [message.id] : [],
139
+ ),
140
+ );
141
+ for (const [id, staged] of state.stagedEntries) {
142
+ if (!visibleStagedIds.has(id)) continue;
143
+ const echoed = baseMessages.some((message, index) => {
144
+ if (matchedBaseMessageIndexes.has(index)) return false;
145
+ if (message.id === id) {
146
+ matchedBaseMessageIndexes.add(index);
147
+ return true;
148
+ }
149
+ if (
150
+ !staged.reconcileOnEcho ||
151
+ index < staged.baseMessageCount ||
152
+ getMessageType(message) !== "human" ||
153
+ !hasSameMessageContent(message, staged.message)
154
+ ) {
155
+ return false;
156
+ }
157
+ matchedBaseMessageIndexes.add(index);
158
+ return true;
159
+ });
160
+ if (echoed) stagedEntries.delete(id);
161
+ else remainingStagedMessages.push(staged.message);
162
+ }
163
+ if (remainingStagedMessages.length === 0) {
164
+ if (
165
+ stagedEntries.size === state.stagedEntries.size &&
166
+ state.stagedBaseMessages === null &&
167
+ state.visibleStagedMessages === null
168
+ ) {
169
+ return state;
170
+ }
171
+ return {
172
+ ...state,
173
+ stagedEntries,
174
+ stagedBaseMessages: null,
175
+ visibleStagedMessages: null,
176
+ };
177
+ }
178
+ return {
179
+ ...state,
180
+ stagedEntries,
181
+ visibleStagedMessages: [...baseMessages, ...remainingStagedMessages],
182
+ };
183
+ }
184
+ case "markTranscript": {
185
+ let stagedEntries: Map<string, StagedEntry> | null = null;
186
+ for (const message of action.messages) {
187
+ const entry = message.id
188
+ ? (stagedEntries ?? state.stagedEntries).get(message.id)
189
+ : undefined;
190
+ if (entry?.transcriptStatus) {
191
+ stagedEntries ??= new Map(state.stagedEntries);
192
+ stagedEntries.set(message.id!, {
193
+ ...entry,
194
+ transcriptStatus: action.status,
195
+ });
196
+ }
197
+ }
198
+ return stagedEntries ? { ...state, stagedEntries } : state;
199
+ }
200
+ case "remove": {
201
+ if (!state.stagedEntries.has(action.id)) return state;
202
+ const stagedEntries = new Map(state.stagedEntries);
203
+ stagedEntries.delete(action.id);
204
+ return {
205
+ ...state,
206
+ stagedEntries,
207
+ stagedBaseMessages:
208
+ stagedEntries.size === 0 ? null : state.stagedBaseMessages,
209
+ visibleStagedMessages:
210
+ stagedEntries.size === 0
211
+ ? null
212
+ : action.visibleMessages.filter(
213
+ (message) => message.id !== action.id,
214
+ ),
215
+ };
216
+ }
217
+ case "promote": {
218
+ const stagedEntries = new Map(state.stagedEntries);
219
+ const promotedIds = new Set<string>();
220
+ for (const message of action.messages) {
221
+ if (!message.id || stagedEntries.get(message.id)?.transcriptStatus)
222
+ continue;
223
+ promotedIds.add(message.id);
224
+ stagedEntries.delete(message.id);
225
+ }
226
+ return {
227
+ ...state,
228
+ stagedEntries,
229
+ stagedBaseMessages: null,
230
+ visibleStagedMessages:
231
+ stagedEntries.size > 0
232
+ ? action.visibleMessages.filter(
233
+ (message) => !message.id || !promotedIds.has(message.id),
234
+ )
235
+ : null,
236
+ };
237
+ }
238
+ }
239
+ };
package/src/types.ts CHANGED
@@ -118,6 +118,11 @@ export type LangChainRuntimeExtraOptions = ExternalStoreSharedOptions & {
118
118
  */
119
119
  onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
120
120
  cloud?: AssistantCloud | undefined;
121
+ /**
122
+ * Stable identity for the account or workspace owning Cloud runtime state.
123
+ * Provide it from the first render and change it when that scope changes.
124
+ */
125
+ scopeId?: string | undefined;
121
126
  adapters?:
122
127
  | {
123
128
  attachments?: AttachmentAdapter | undefined;
@@ -137,11 +142,15 @@ export type LangChainRuntimeExtraOptions = ExternalStoreSharedOptions & {
137
142
  /**
138
143
  * Routes the Cancel button's click to `useStream().stop()`. On by
139
144
  * default. Pass `false` to disable the Cancel button.
145
+ *
146
+ * @deprecated Experimental since 2025-01-03. Not scheduled for removal; the API may change in any release.
140
147
  */
141
148
  unstable_allowCancellation?: boolean | undefined;
142
149
  /**
143
150
  * Custom `RemoteThreadListAdapter`. When provided, replaces the
144
151
  * cloud-backed thread list adapter.
152
+ *
153
+ * @deprecated Experimental since 2026-04-16. Not scheduled for removal; the API may change in any release.
145
154
  */
146
155
  unstable_threadListAdapter?: RemoteThreadListAdapter | undefined;
147
156
  /** Custom thread-creation hook, forwarded to the cloud adapter. */
@@ -0,0 +1,61 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { renderHook } from "@testing-library/react";
4
+ import { describe, expect, it, vi } from "vitest";
5
+ import type { AssistantCloud } from "assistant-cloud";
6
+ import type { AssistantRuntime } from "@assistant-ui/core";
7
+ import type { UseStreamRuntimeOptions } from "./types";
8
+
9
+ const mocks = vi.hoisted(() => ({
10
+ cloudAdapter: {},
11
+ runtime: {},
12
+ useCloudThreadListAdapter: vi.fn(() => ({})),
13
+ useRemoteThreadListRuntime: vi.fn(() => ({})),
14
+ }));
15
+
16
+ vi.mock("@assistant-ui/core/react", async (importOriginal) => ({
17
+ ...(await importOriginal<typeof import("@assistant-ui/core/react")>()),
18
+ useCloudThreadListAdapter: mocks.useCloudThreadListAdapter,
19
+ useRemoteThreadListRuntime: mocks.useRemoteThreadListRuntime,
20
+ }));
21
+
22
+ vi.mock("@assistant-ui/store", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("@assistant-ui/store")>()),
24
+ useAui: () => ({
25
+ threadListItem: {
26
+ source: null,
27
+ getState: () => ({ externalId: undefined }),
28
+ initialize: vi.fn(),
29
+ },
30
+ }),
31
+ }));
32
+
33
+ import { LANGCHAIN_SDK } from "./sdkIdentity";
34
+ import { useStreamRuntime } from "./useStreamRuntime";
35
+
36
+ describe("useStreamRuntime Cloud options", () => {
37
+ it("forwards the Cloud scope to the thread-list adapter", () => {
38
+ const cloud = {} as AssistantCloud;
39
+ mocks.useCloudThreadListAdapter.mockReturnValue(mocks.cloudAdapter);
40
+ mocks.useRemoteThreadListRuntime.mockReturnValue(
41
+ mocks.runtime as AssistantRuntime,
42
+ );
43
+
44
+ const options = {
45
+ apiUrl: "/api",
46
+ assistantId: "assistant-1",
47
+ cloud,
48
+ scopeId: "workspace-1",
49
+ } satisfies UseStreamRuntimeOptions;
50
+
51
+ renderHook(() => useStreamRuntime(options));
52
+
53
+ expect(mocks.useCloudThreadListAdapter).toHaveBeenCalledWith(
54
+ expect.objectContaining({
55
+ cloud,
56
+ scopeId: "workspace-1",
57
+ sdk: LANGCHAIN_SDK,
58
+ }),
59
+ );
60
+ });
61
+ });