@assistant-ui/react-langchain 0.0.35 → 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 (36) 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.map +1 -1
  6. package/dist/convertMessages.js +6 -11
  7. package/dist/convertMessages.js.map +1 -1
  8. package/dist/converter.d.ts +24 -1
  9. package/dist/converter.d.ts.map +1 -1
  10. package/dist/converter.js +84 -18
  11. package/dist/converter.js.map +1 -1
  12. package/dist/langChainThreadState.d.ts +54 -0
  13. package/dist/langChainThreadState.d.ts.map +1 -0
  14. package/dist/langChainThreadState.js +135 -0
  15. package/dist/langChainThreadState.js.map +1 -0
  16. package/dist/sdkIdentity.js +1 -1
  17. package/dist/types.d.ts +9 -0
  18. package/dist/types.d.ts.map +1 -1
  19. package/dist/useStreamRuntime.d.ts +3 -2
  20. package/dist/useStreamRuntime.d.ts.map +1 -1
  21. package/dist/useStreamRuntime.js +152 -139
  22. package/dist/useStreamRuntime.js.map +1 -1
  23. package/dist/useSubagentTranscripts.js +1 -1
  24. package/package.json +8 -8
  25. package/src/LangChainThreadController.ts +24 -0
  26. package/src/convertMessages.test.ts +23 -0
  27. package/src/convertMessages.ts +9 -16
  28. package/src/converter.test.ts +322 -0
  29. package/src/converter.ts +138 -27
  30. package/src/getPendingToolCalls.test.ts +35 -0
  31. package/src/langChainThreadState.test.ts +203 -0
  32. package/src/langChainThreadState.ts +239 -0
  33. package/src/types.ts +9 -0
  34. package/src/useStreamRuntime.cloud-options.test.tsx +61 -0
  35. package/src/useStreamRuntime.test.tsx +227 -1
  36. package/src/useStreamRuntime.ts +210 -239
package/src/converter.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  resolveFilePartSource,
11
11
  } from "@assistant-ui/core/internal";
12
12
  import type { StreamingTimingAccessors } from "@assistant-ui/core/react";
13
+ import type { ReadonlyJSONObject } from "assistant-stream/utils";
13
14
 
14
15
  /** Known content block types from @langchain/core messages. */
15
16
  export type LangChainContentBlock =
@@ -51,12 +52,143 @@ export type LangChainContentBlock =
51
52
  }
52
53
  | { type: "tool_use" | "input_json_delta" };
53
54
 
55
+ /**
56
+ * One block covers both multimodal vocabularies: the standard content blocks
57
+ * key their payload off a camelCase JavaScript field or snake_case Python
58
+ * field, while the legacy data content blocks use a `source_type` discriminator.
59
+ */
60
+ type LangChainMediaBlock = {
61
+ type: "image" | "video" | "audio" | "file" | "text-plain";
62
+ mimeType?: string;
63
+ mime_type?: string;
64
+ data?: string | Uint8Array;
65
+ base64?: string | Uint8Array;
66
+ url?: string;
67
+ fileId?: string;
68
+ file_id?: string;
69
+ id?: string;
70
+ text?: string;
71
+ source_type?: "base64" | "url" | "id" | "text";
72
+ metadata?: { filename?: string };
73
+ };
74
+
54
75
  type ConvertedContentPart =
55
76
  | ThreadUserMessage["content"][number]
56
77
  | ThreadAssistantMessage["content"][number];
57
78
 
79
+ type MediaSource = { data: string; sourceType?: "url" | "id" };
80
+
81
+ /**
82
+ * `id` names the block, not the payload, in the standard content blocks, so it
83
+ * only identifies a file under the legacy `source_type: "id"` discriminator.
84
+ */
85
+ const resolveMediaSource = (
86
+ part: LangChainMediaBlock,
87
+ ): MediaSource | undefined => {
88
+ switch (part.source_type) {
89
+ case "url":
90
+ return typeof part.url === "string"
91
+ ? { data: part.url, sourceType: "url" }
92
+ : undefined;
93
+ case "id":
94
+ return typeof part.id === "string"
95
+ ? { data: part.id, sourceType: "id" }
96
+ : undefined;
97
+ case "base64": {
98
+ const data = part.data ?? part.base64;
99
+ return typeof data === "string" ? { data } : undefined;
100
+ }
101
+ }
102
+ if (typeof part.url === "string")
103
+ return { data: part.url, sourceType: "url" };
104
+ const fileId = part.fileId ?? part.file_id;
105
+ if (typeof fileId === "string") return { data: fileId, sourceType: "id" };
106
+ const data = part.data ?? part.base64;
107
+ if (typeof data === "string") return { data };
108
+ if (part.type === "text-plain" && typeof part.text === "string")
109
+ return { data: textToBase64(part.text) };
110
+ return undefined;
111
+ };
112
+
113
+ const textToBase64 = (text: string): string => {
114
+ const bytes = new TextEncoder().encode(text);
115
+ let binary = "";
116
+ for (let i = 0; i < bytes.length; i += 0x8000) {
117
+ binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
118
+ }
119
+ return btoa(binary);
120
+ };
121
+
122
+ const resolveMediaMimeType = (part: LangChainMediaBlock): string | undefined =>
123
+ typeof part.mimeType === "string"
124
+ ? part.mimeType
125
+ : typeof part.mime_type === "string"
126
+ ? part.mime_type
127
+ : undefined;
128
+
129
+ const defaultMediaFilename = (type: string, mimeType: string): string => {
130
+ if (type !== "audio" && type !== "video") return "file";
131
+ const subtype = mimeType.startsWith(`${type}/`)
132
+ ? mimeType.slice(type.length + 1)
133
+ : undefined;
134
+ return subtype ? `${type}.${subtype}` : type;
135
+ };
136
+
137
+ const resolveImage = (
138
+ source: MediaSource,
139
+ mimeType: string | undefined,
140
+ ): string | undefined => {
141
+ if (source.sourceType === "id") return undefined;
142
+ // `parseDataUrl` only matches the base64 form, so a percent-encoded data URL
143
+ // would otherwise be wrapped in a second base64 envelope.
144
+ if (source.sourceType === "url" || /^data:/i.test(source.data))
145
+ return source.data;
146
+ return mimeType ? `data:${mimeType};base64,${source.data}` : undefined;
147
+ };
148
+
149
+ const convertMediaBlock = (
150
+ part: LangChainMediaBlock,
151
+ ): ConvertedContentPart | undefined => {
152
+ const source = resolveMediaSource(part);
153
+ if (!source) return undefined;
154
+
155
+ const mimeType = resolveMediaMimeType(part);
156
+ if (part.type === "image") {
157
+ const image = resolveImage(source, mimeType);
158
+ if (image) return { type: "image" as const, image };
159
+ }
160
+
161
+ const resolvedMimeType =
162
+ mimeType ??
163
+ (part.type === "text-plain" ? "text/plain" : "application/octet-stream");
164
+ return {
165
+ type: "file" as const,
166
+ filename:
167
+ part.metadata?.filename ??
168
+ defaultMediaFilename(part.type, resolvedMimeType),
169
+ data: source.data,
170
+ mimeType: resolvedMimeType,
171
+ ...(source.sourceType && { sourceType: source.sourceType }),
172
+ };
173
+ };
174
+
175
+ export const normalizeToolCallArgs = (args: unknown): ReadonlyJSONObject => {
176
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
177
+ return {};
178
+ }
179
+
180
+ try {
181
+ const prototype = Object.getPrototypeOf(args);
182
+ return prototype === Object.prototype || prototype === null
183
+ ? (args as ReadonlyJSONObject)
184
+ : {};
185
+ } catch {
186
+ return {};
187
+ }
188
+ };
189
+
58
190
  export const convertLangChainContentBlock = (
59
- part: LangChainContentBlock,
191
+ part: LangChainContentBlock | LangChainMediaBlock,
60
192
  ): ConvertedContentPart | null | undefined => {
61
193
  const type = part.type;
62
194
  switch (type) {
@@ -74,33 +206,12 @@ export const convertLangChainContentBlock = (
74
206
  if (!image) return null;
75
207
  return { type: "image" as const, image };
76
208
  }
209
+ case "image":
210
+ case "video":
211
+ case "audio":
77
212
  case "file":
78
- return {
79
- type: "file" as const,
80
- filename: part.metadata?.filename ?? "file",
81
- data:
82
- part.source_type === "url"
83
- ? part.url
84
- : part.source_type === "id"
85
- ? part.id
86
- : part.data,
87
- mimeType: part.mime_type ?? "application/octet-stream",
88
- ...((part.source_type === "url" || part.source_type === "id") && {
89
- sourceType: part.source_type,
90
- }),
91
- };
92
- case "audio": {
93
- const mimeType = part.mime_type ?? "application/octet-stream";
94
- const subtype = mimeType.startsWith("audio/")
95
- ? mimeType.slice("audio/".length)
96
- : undefined;
97
- return {
98
- type: "file" as const,
99
- filename: subtype ? `audio.${subtype}` : "audio",
100
- data: part.data,
101
- mimeType,
102
- };
103
- }
213
+ case "text-plain":
214
+ return convertMediaBlock(part);
104
215
  case "thinking":
105
216
  return hasVisibleText(part.thinking)
106
217
  ? { type: "reasoning" as const, text: part.thinking }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { LangChainBaseMessage } from "./types";
3
+ import { getPendingToolCalls } from "./useStreamRuntime";
4
+
5
+ const aiWithNullToolCall = {
6
+ id: "ai-1",
7
+ _getType: () => "ai",
8
+ content: "",
9
+ tool_calls: [null, { id: "call-1", name: "lookup", args: {} }],
10
+ } as unknown as LangChainBaseMessage;
11
+
12
+ describe("getPendingToolCalls", () => {
13
+ it("skips a null tool_calls entry and returns the remaining pending call", () => {
14
+ expect(
15
+ getPendingToolCalls([
16
+ { id: "human-1", _getType: () => "human", content: "look it up" },
17
+ aiWithNullToolCall,
18
+ ]),
19
+ ).toEqual([{ id: "call-1", name: "lookup", args: {} }]);
20
+ });
21
+
22
+ it("treats a call answered by a tool message as settled next to a null entry", () => {
23
+ expect(
24
+ getPendingToolCalls([
25
+ aiWithNullToolCall,
26
+ {
27
+ id: "tool-1",
28
+ _getType: () => "tool",
29
+ content: "done",
30
+ tool_call_id: "call-1",
31
+ } as LangChainBaseMessage,
32
+ ]),
33
+ ).toEqual([]);
34
+ });
35
+ });
@@ -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. */