@assistant-ui/react-langchain 0.0.15 → 0.0.17

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 (64) hide show
  1. package/dist/convertMessages.d.ts +37 -3
  2. package/dist/convertMessages.d.ts.map +1 -1
  3. package/dist/convertMessages.js +57 -11
  4. package/dist/convertMessages.js.map +1 -1
  5. package/dist/findForkCheckpointInHistory.d.ts +26 -0
  6. package/dist/findForkCheckpointInHistory.d.ts.map +1 -0
  7. package/dist/findForkCheckpointInHistory.js +34 -0
  8. package/dist/findForkCheckpointInHistory.js.map +1 -0
  9. package/dist/hooks.d.ts +88 -0
  10. package/dist/hooks.d.ts.map +1 -0
  11. package/dist/hooks.js +99 -0
  12. package/dist/hooks.js.map +1 -0
  13. package/dist/index.d.ts +6 -3
  14. package/dist/index.js +4 -2
  15. package/dist/resolveForkCheckpoint.d.ts +17 -0
  16. package/dist/resolveForkCheckpoint.d.ts.map +1 -0
  17. package/dist/resolveForkCheckpoint.js +26 -0
  18. package/dist/resolveForkCheckpoint.js.map +1 -0
  19. package/dist/runtimeExtras.d.ts +7 -0
  20. package/dist/runtimeExtras.d.ts.map +1 -0
  21. package/dist/runtimeExtras.js +7 -0
  22. package/dist/runtimeExtras.js.map +1 -0
  23. package/dist/streamingTiming.d.ts +16 -0
  24. package/dist/streamingTiming.d.ts.map +1 -0
  25. package/dist/streamingTiming.js +53 -0
  26. package/dist/streamingTiming.js.map +1 -0
  27. package/dist/types.d.ts +105 -5
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/uiMessages.d.ts +24 -0
  30. package/dist/uiMessages.d.ts.map +1 -0
  31. package/dist/uiMessages.js +68 -0
  32. package/dist/uiMessages.js.map +1 -0
  33. package/dist/useStreamRuntime.d.ts +10 -85
  34. package/dist/useStreamRuntime.d.ts.map +1 -1
  35. package/dist/useStreamRuntime.js +186 -135
  36. package/dist/useStreamRuntime.js.map +1 -1
  37. package/package.json +9 -9
  38. package/src/__tests__/langChainTestUtils.ts +3 -3
  39. package/src/convertMessages.test.ts +160 -3
  40. package/src/convertMessages.ts +104 -11
  41. package/src/findForkCheckpointInHistory.test.ts +263 -0
  42. package/src/findForkCheckpointInHistory.ts +68 -0
  43. package/src/groupUIMessagesByParent.test.ts +61 -0
  44. package/src/hooks.ts +156 -0
  45. package/src/index.ts +18 -3
  46. package/src/resolveForkCheckpoint.test.ts +202 -0
  47. package/src/resolveForkCheckpoint.ts +49 -0
  48. package/src/runtimeExtras.ts +5 -0
  49. package/src/streamingTiming.test.ts +118 -0
  50. package/src/streamingTiming.ts +85 -0
  51. package/src/types.ts +140 -3
  52. package/src/uiMessages.test.ts +190 -0
  53. package/src/uiMessages.ts +91 -0
  54. package/src/useLangChainError.test.tsx +1 -1
  55. package/src/useLangChainInterrupts.test.tsx +48 -0
  56. package/src/useLangChainRespond.test.tsx +49 -0
  57. package/src/useLangChainRespondAll.test.tsx +52 -0
  58. package/src/useLangChainState.test.tsx +1 -1
  59. package/src/useLangChainStream.test.tsx +42 -0
  60. package/src/useLangChainSubagents.test.tsx +48 -0
  61. package/src/useLangChainSubgraphs.test.tsx +48 -0
  62. package/src/useLangChainToolCalls.test.tsx +1 -1
  63. package/src/useStreamRuntime.test.tsx +232 -0
  64. package/src/useStreamRuntime.ts +301 -268
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import type { MessageTiming } from "@assistant-ui/core";
4
+ import {
5
+ useStreamingTiming,
6
+ type StreamingTimingAccessors,
7
+ } from "@assistant-ui/core/react";
8
+ import type { LangChainBaseMessage, LangChainContentBlock } from "./types";
9
+ import { getMessageType } from "./convertMessages";
10
+
11
+ const findAiMessage = (
12
+ messages: readonly LangChainBaseMessage[],
13
+ messageId: string,
14
+ ): LangChainBaseMessage | undefined =>
15
+ messages.find((m) => getMessageType(m) === "ai" && m.id === messageId);
16
+
17
+ const reasoningTextLength = (part: {
18
+ readonly summary?: ReadonlyArray<{ readonly text?: string }>;
19
+ readonly reasoning?: string;
20
+ }): number => {
21
+ if (part.summary && part.summary.length > 0)
22
+ return part.summary.map((s) => s?.text ?? "").join("\n\n\n").length;
23
+ return part.reasoning?.length ?? 0;
24
+ };
25
+
26
+ const getTextLength = (
27
+ messages: readonly LangChainBaseMessage[],
28
+ messageId: string,
29
+ ): number => {
30
+ const m = findAiMessage(messages, messageId);
31
+ if (!m) return 0;
32
+ const content = m.content;
33
+ if (typeof content === "string") return content.length;
34
+ if (!Array.isArray(content)) return 0;
35
+ let len = 0;
36
+ for (const part of content as readonly LangChainContentBlock[]) {
37
+ switch (part.type) {
38
+ case "text":
39
+ case "text_delta":
40
+ if (typeof part.text === "string") len += part.text.length;
41
+ break;
42
+ case "thinking":
43
+ if (typeof part.thinking === "string") len += part.thinking.length;
44
+ break;
45
+ case "reasoning":
46
+ len += reasoningTextLength(part);
47
+ break;
48
+ }
49
+ }
50
+ return len;
51
+ };
52
+
53
+ const getToolCallCount = (
54
+ messages: readonly LangChainBaseMessage[],
55
+ messageId: string,
56
+ ): number => findAiMessage(messages, messageId)?.tool_calls?.length ?? 0;
57
+
58
+ const getAssistantMessageId = (
59
+ messages: readonly LangChainBaseMessage[],
60
+ ): string | undefined => {
61
+ for (let i = messages.length - 1; i >= 0; i--) {
62
+ const m = messages[i];
63
+ if (m && getMessageType(m) === "ai" && m.id) return m.id;
64
+ }
65
+ return undefined;
66
+ };
67
+
68
+ export const langChainStreamingTimingAccessors: StreamingTimingAccessors<LangChainBaseMessage> =
69
+ {
70
+ getAssistantMessageId,
71
+ getTextLength,
72
+ getToolCallCount,
73
+ };
74
+
75
+ /**
76
+ * Tracks per-message streaming timing for LangChain messages. Delegates to
77
+ * the shared `useStreamingTiming` primitive in `@assistant-ui/core/react`,
78
+ * adapted to the `LangChainBaseMessage` shape (`_getType() -> "ai"`, content
79
+ * blocks including text/thinking/reasoning, `tool_calls`).
80
+ */
81
+ export const useLangChainStreamingTiming = (
82
+ messages: readonly LangChainBaseMessage[],
83
+ isRunning: boolean,
84
+ ): Record<string, MessageTiming> =>
85
+ useStreamingTiming(messages, isRunning, langChainStreamingTimingAccessors);
package/src/types.ts CHANGED
@@ -1,14 +1,32 @@
1
- import type { MessageStatus } from "@assistant-ui/core";
1
+ import type {
2
+ AttachmentAdapter,
3
+ DictationAdapter,
4
+ ExternalStoreSharedOptions,
5
+ FeedbackAdapter,
6
+ MessageStatus,
7
+ RealtimeVoiceAdapter,
8
+ RemoteThreadListAdapter,
9
+ SpeechSynthesisAdapter,
10
+ } from "@assistant-ui/core";
11
+ import type { AssistantCloud } from "assistant-cloud";
12
+ import type {
13
+ UseStreamOptions,
14
+ AssembledToolCall,
15
+ AnyStream,
16
+ SubagentDiscoverySnapshot,
17
+ SubgraphDiscoverySnapshot,
18
+ } from "@langchain/react";
2
19
 
3
20
  /** Known content block types from @langchain/core messages. */
4
21
  export type LangChainContentBlock =
5
22
  | { type: "text"; text: string }
6
23
  | { type: "text_delta"; text: string }
7
- | { type: "image_url"; image_url: string | { url: string } }
24
+ | { type: "image_url"; image_url: string | { url?: string } }
8
25
  | { type: "thinking"; thinking: string }
9
26
  | {
10
27
  type: "reasoning";
11
- summary: Array<{ type: "summary_text"; text: string }>;
28
+ summary?: Array<{ type: "summary_text"; text?: string }>;
29
+ reasoning?: string;
12
30
  }
13
31
  | {
14
32
  type: "file";
@@ -25,6 +43,41 @@ export type LangChainToolCall = {
25
43
  args: Record<string, unknown>;
26
44
  };
27
45
 
46
+ /**
47
+ * A generative UI component the graph accumulates in its state. Read from
48
+ * `stream.values[uiStateKey]` and rendered via `makeAssistantDataUI`.
49
+ *
50
+ * The parent assistant message is carried in `metadata.message_id` by the
51
+ * Python SDK and in `metadata.id` by the JS SDK; the runtime reads either.
52
+ */
53
+ export type UIMessage<
54
+ TName extends string = string,
55
+ TProps extends Record<string, unknown> = Record<string, unknown>,
56
+ > = {
57
+ type: "ui";
58
+ id: string;
59
+ name: TName;
60
+ props: TProps;
61
+ metadata?: {
62
+ merge?: boolean;
63
+ run_id?: string;
64
+ name?: string;
65
+ tags?: string[];
66
+ message_id?: string;
67
+ id?: string;
68
+ [key: string]: unknown;
69
+ };
70
+ };
71
+
72
+ /**
73
+ * Emitted on the live `custom` channel by the graph's `remove_ui_message`
74
+ * helper to drop a pushed `UIMessage` by id.
75
+ */
76
+ export type RemoveUIMessage = {
77
+ type: "remove-ui";
78
+ id: string;
79
+ };
80
+
28
81
  /**
29
82
  * Minimal duck-typed interface for BaseMessage class instances returned by
30
83
  * `useStream`. Used internally by the message converter.
@@ -47,3 +100,87 @@ export type LangChainBaseMessage = {
47
100
  /** Present on ToolMessage */
48
101
  artifact?: unknown;
49
102
  };
103
+
104
+ export type LangChainRuntimeExtras = {
105
+ interrupt: { value?: unknown } | undefined;
106
+ interrupts: readonly { id?: string; value?: unknown }[];
107
+ toolCalls: readonly AssembledToolCall[];
108
+ subagents: ReadonlyMap<string, SubagentDiscoverySnapshot>;
109
+ subgraphs: ReadonlyMap<string, SubgraphDiscoverySnapshot>;
110
+ stream: AnyStream;
111
+ error: unknown;
112
+ submit: (
113
+ values: Record<string, unknown> | null | undefined,
114
+ options?: Record<string, unknown>,
115
+ ) => Promise<void>;
116
+ respond: (
117
+ response: unknown,
118
+ options?: Record<string, unknown>,
119
+ ) => Promise<void>;
120
+ respondAll: (
121
+ responsesById: Record<string, unknown>,
122
+ options?: Record<string, unknown>,
123
+ ) => Promise<void>;
124
+ values: Record<string, unknown>;
125
+ messagesKey: string;
126
+ };
127
+
128
+ export type LangChainRuntimeExtraOptions = ExternalStoreSharedOptions & {
129
+ /**
130
+ * Called whenever the active thread's canonical (remote) ID changes, so the
131
+ * value can be treated as a managed/controlled variable (e.g. synced to a URL
132
+ * query param). Only the settled remote ID is emitted: while a freshly created
133
+ * thread is still optimistic the value is `undefined`, and the real ID is
134
+ * emitted once the thread is initialized; the transient local ID is never
135
+ * surfaced.
136
+ */
137
+ onThreadIdChange?: ((threadId: string | undefined) => void) | undefined;
138
+ cloud?: AssistantCloud | undefined;
139
+ adapters?:
140
+ | {
141
+ attachments?: AttachmentAdapter | undefined;
142
+ speech?: SpeechSynthesisAdapter | undefined;
143
+ dictation?: DictationAdapter | undefined;
144
+ voice?: RealtimeVoiceAdapter | undefined;
145
+ feedback?: FeedbackAdapter | undefined;
146
+ }
147
+ | undefined;
148
+ /**
149
+ * When the user sends a new message while previous tool calls are
150
+ * still pending, automatically submit `tool` messages that cancel
151
+ * them so the agent's tool-call accounting stays consistent.
152
+ * Defaults to `true`.
153
+ */
154
+ autoCancelPendingToolCalls?: boolean | undefined;
155
+ /**
156
+ * Routes the Cancel button's click to `useStream().stop()`. On by
157
+ * default. Pass `false` to disable the Cancel button.
158
+ */
159
+ unstable_allowCancellation?: boolean | undefined;
160
+ /**
161
+ * Custom `RemoteThreadListAdapter`. When provided, replaces the
162
+ * cloud-backed thread list adapter.
163
+ */
164
+ unstable_threadListAdapter?: RemoteThreadListAdapter | undefined;
165
+ /** Custom thread-creation hook, forwarded to the cloud adapter. */
166
+ create?: (() => Promise<{ externalId: string | undefined }>) | undefined;
167
+ /** Custom thread-deletion hook, forwarded to the cloud adapter. */
168
+ delete?: ((threadId: string) => Promise<void>) | undefined;
169
+ /**
170
+ * State key the graph accumulates generative `UIMessage`s under. Each UI
171
+ * is attached to the assistant message identified by its `metadata.message_id`
172
+ * (Python SDK) or `metadata.id` (JS SDK) and emitted as a `data` part.
173
+ * Defaults to `"ui"`.
174
+ */
175
+ uiStateKey?: string | undefined;
176
+ };
177
+
178
+ // Distribute the intersection through the union arms of `UseStreamOptions`
179
+ // (`AgentServerOptions | CustomAdapterOptions`). Writing `UseStreamOptions & X`
180
+ // directly collapses arm tracking, so `Omit<…, "cloud">` and the like would
181
+ // produce a flattened structural type that no longer matches either arm.
182
+ export type UseStreamRuntimeOptions = UseStreamOptions extends infer O
183
+ ? O extends UseStreamOptions
184
+ ? O & LangChainRuntimeExtraOptions
185
+ : never
186
+ : never;
@@ -0,0 +1,190 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applyUIUpdate,
4
+ extractUIUpdate,
5
+ foldUIUpdates,
6
+ isUIUpdate,
7
+ mergeUIMessages,
8
+ } from "./uiMessages";
9
+ import type { UIMessage } from "./types";
10
+
11
+ const ui = (id: string, props: Record<string, unknown> = {}): UIMessage => ({
12
+ type: "ui",
13
+ id,
14
+ name: "chart",
15
+ props,
16
+ });
17
+
18
+ describe("isUIUpdate", () => {
19
+ it("accepts ui and remove-ui objects with a string id", () => {
20
+ expect(isUIUpdate({ type: "ui", id: "a", name: "x", props: {} })).toBe(
21
+ true,
22
+ );
23
+ expect(isUIUpdate({ type: "remove-ui", id: "a" })).toBe(true);
24
+ });
25
+
26
+ it("accepts an array of updates", () => {
27
+ expect(
28
+ isUIUpdate([
29
+ { type: "ui", id: "a", name: "x", props: {} },
30
+ { type: "remove-ui", id: "b" },
31
+ ]),
32
+ ).toBe(true);
33
+ });
34
+
35
+ it("rejects non-UI values", () => {
36
+ expect(isUIUpdate(null)).toBe(false);
37
+ expect(isUIUpdate("ui")).toBe(false);
38
+ expect(isUIUpdate({ type: "ui", name: "x" })).toBe(false);
39
+ expect(isUIUpdate({ type: "other", id: "a" })).toBe(false);
40
+ expect(isUIUpdate([{ type: "ui", id: "a", name: "x", props: {} }, 5])).toBe(
41
+ false,
42
+ );
43
+ });
44
+
45
+ it("rejects a ui update missing name or props", () => {
46
+ expect(isUIUpdate({ type: "ui", id: "a" })).toBe(false);
47
+ expect(isUIUpdate({ type: "ui", id: "a", name: "x" })).toBe(false);
48
+ expect(isUIUpdate({ type: "ui", id: "a", props: {} })).toBe(false);
49
+ expect(isUIUpdate({ type: "ui", id: "a", name: "x", props: null })).toBe(
50
+ false,
51
+ );
52
+ });
53
+
54
+ it("rejects an empty array", () => {
55
+ expect(isUIUpdate([])).toBe(false);
56
+ });
57
+ });
58
+
59
+ describe("applyUIUpdate", () => {
60
+ it("pushes a new UI message", () => {
61
+ expect(applyUIUpdate([], ui("a"))).toEqual([ui("a")]);
62
+ });
63
+
64
+ it("replaces an existing UI message by id", () => {
65
+ const result = applyUIUpdate([ui("a", { v: 1 })], ui("a", { v: 2 }));
66
+ expect(result).toEqual([ui("a", { v: 2 })]);
67
+ });
68
+
69
+ it("merges props when metadata.merge is true", () => {
70
+ const result = applyUIUpdate([ui("a", { keep: 1, v: 1 })], {
71
+ ...ui("a", { v: 2 }),
72
+ metadata: { merge: true },
73
+ });
74
+ expect(result).toEqual([
75
+ { ...ui("a", { keep: 1, v: 2 }), metadata: { merge: true } },
76
+ ]);
77
+ });
78
+
79
+ it("removes a UI message by id", () => {
80
+ const result = applyUIUpdate([ui("a"), ui("b")], {
81
+ type: "remove-ui",
82
+ id: "a",
83
+ });
84
+ expect(result).toEqual([ui("b")]);
85
+ });
86
+
87
+ it("applies an array of updates in order", () => {
88
+ const result = applyUIUpdate(
89
+ [ui("a")],
90
+ [ui("b"), { type: "remove-ui", id: "a" }, ui("c")],
91
+ );
92
+ expect(result).toEqual([ui("b"), ui("c")]);
93
+ });
94
+
95
+ it("does not mutate the input list", () => {
96
+ const input = [ui("a")];
97
+ applyUIUpdate(input, ui("b"));
98
+ expect(input).toEqual([ui("a")]);
99
+ });
100
+ });
101
+
102
+ describe("extractUIUpdate", () => {
103
+ it("reads a UI update straight from params.data", () => {
104
+ const event = {
105
+ params: { data: { type: "ui", id: "a", name: "x", props: {} } },
106
+ };
107
+ expect(extractUIUpdate(event)).toEqual({
108
+ type: "ui",
109
+ id: "a",
110
+ name: "x",
111
+ props: {},
112
+ });
113
+ });
114
+
115
+ it("falls back to params.data.payload when data wraps the update", () => {
116
+ const event = {
117
+ params: { data: { payload: { type: "remove-ui", id: "a" } } },
118
+ };
119
+ expect(extractUIUpdate(event)).toEqual({ type: "remove-ui", id: "a" });
120
+ });
121
+
122
+ it("reads an array of updates", () => {
123
+ const event = {
124
+ params: { data: [ui("a"), { type: "remove-ui", id: "b" }] },
125
+ };
126
+ expect(extractUIUpdate(event)).toEqual([
127
+ ui("a"),
128
+ { type: "remove-ui", id: "b" },
129
+ ]);
130
+ });
131
+
132
+ it("returns undefined for non-UI custom events and malformed shapes", () => {
133
+ expect(extractUIUpdate({ params: { data: { foo: 1 } } })).toBeUndefined();
134
+ expect(extractUIUpdate({ params: {} })).toBeUndefined();
135
+ expect(extractUIUpdate({})).toBeUndefined();
136
+ expect(extractUIUpdate(null)).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe("mergeUIMessages", () => {
141
+ it("returns the snapshot when there are no live messages", () => {
142
+ expect(mergeUIMessages([], [ui("a")])).toEqual([ui("a")]);
143
+ });
144
+
145
+ it("returns live messages when the snapshot is not an array", () => {
146
+ expect(mergeUIMessages([ui("a")], undefined)).toEqual([ui("a")]);
147
+ });
148
+
149
+ it("lets the snapshot win by id", () => {
150
+ const result = mergeUIMessages([ui("a", { v: 1 })], [ui("a", { v: 2 })]);
151
+ expect(result).toEqual([ui("a", { v: 2 })]);
152
+ });
153
+
154
+ it("keeps live and snapshot entries with distinct ids", () => {
155
+ expect(mergeUIMessages([ui("a")], [ui("b")])).toEqual([ui("a"), ui("b")]);
156
+ });
157
+ });
158
+
159
+ describe("foldUIUpdates", () => {
160
+ const evt = (data: unknown) => ({ params: { data } });
161
+
162
+ it("folds a sequence of custom events into a UI list", () => {
163
+ expect(foldUIUpdates([evt(ui("a")), evt(ui("b"))])).toEqual([
164
+ ui("a"),
165
+ ui("b"),
166
+ ]);
167
+ });
168
+
169
+ it("reads updates nested under params.data.payload", () => {
170
+ expect(foldUIUpdates([evt({ payload: ui("a") })])).toEqual([ui("a")]);
171
+ });
172
+
173
+ it("applies a remove across events", () => {
174
+ const result = foldUIUpdates([
175
+ evt(ui("a")),
176
+ evt(ui("b")),
177
+ evt({ type: "remove-ui", id: "a" }),
178
+ ]);
179
+ expect(result).toEqual([ui("b")]);
180
+ });
181
+
182
+ it("ignores non-UI custom events", () => {
183
+ const result = foldUIUpdates([evt(ui("a")), evt({ foo: 1 }), evt(ui("b"))]);
184
+ expect(result).toEqual([ui("a"), ui("b")]);
185
+ });
186
+
187
+ it("returns an empty list for no events", () => {
188
+ expect(foldUIUpdates([])).toEqual([]);
189
+ });
190
+ });
@@ -0,0 +1,91 @@
1
+ import type { RemoveUIMessage, UIMessage } from "./types";
2
+
3
+ export type UIUpdate = UIMessage | RemoveUIMessage;
4
+
5
+ export const isUIUpdate = (
6
+ value: unknown,
7
+ ): value is UIUpdate | readonly UIUpdate[] => {
8
+ if (Array.isArray(value)) return value.length > 0 && value.every(isUIUpdate);
9
+ if (value == null || typeof value !== "object") return false;
10
+ const v = value as {
11
+ type?: unknown;
12
+ id?: unknown;
13
+ name?: unknown;
14
+ props?: unknown;
15
+ };
16
+ if (typeof v.id !== "string") return false;
17
+ if (v.type === "remove-ui") return true;
18
+ return (
19
+ v.type === "ui" &&
20
+ typeof v.name === "string" &&
21
+ typeof v.props === "object" &&
22
+ v.props !== null
23
+ );
24
+ };
25
+
26
+ export const applyUIUpdate = (
27
+ list: readonly UIMessage[],
28
+ update: UIUpdate | readonly UIUpdate[],
29
+ ): UIMessage[] => {
30
+ const events = Array.isArray(update) ? update : [update as UIUpdate];
31
+ let next = list.slice();
32
+ for (const event of events) {
33
+ if (event.type === "remove-ui") {
34
+ next = next.filter((ui) => ui.id !== event.id);
35
+ continue;
36
+ }
37
+ const index = next.findIndex((ui) => ui.id === event.id);
38
+ if (index === -1) {
39
+ next.push(event);
40
+ continue;
41
+ }
42
+ next[index] =
43
+ event.metadata?.merge === true
44
+ ? { ...event, props: { ...next[index]!.props, ...event.props } }
45
+ : event;
46
+ }
47
+ return next;
48
+ };
49
+
50
+ /**
51
+ * Pulls a UI update out of a raw `custom`-channel event. The graph writes the
52
+ * `UIMessage` straight to the channel, so it lands at `params.data`; some
53
+ * transports wrap it one level deeper at `params.data.payload`.
54
+ */
55
+ export const extractUIUpdate = (
56
+ event: unknown,
57
+ ): UIUpdate | readonly UIUpdate[] | undefined => {
58
+ const data = (event as { params?: { data?: unknown } } | null)?.params?.data;
59
+ if (isUIUpdate(data)) return data;
60
+ const payload = (data as { payload?: unknown } | undefined)?.payload;
61
+ if (isUIUpdate(payload)) return payload;
62
+ return undefined;
63
+ };
64
+
65
+ export const foldUIUpdates = (events: readonly unknown[]): UIMessage[] => {
66
+ let acc: UIMessage[] = [];
67
+ for (const event of events) {
68
+ const update = extractUIUpdate(event);
69
+ if (update) acc = applyUIUpdate(acc, update);
70
+ }
71
+ return acc;
72
+ };
73
+
74
+ /**
75
+ * Merges live-streamed UI with the state snapshot. The snapshot is
76
+ * authoritative by id: once a UI lands in graph state it supersedes its live
77
+ * copy. A consequence is that a live `remove-ui` is overridden while the
78
+ * snapshot still contains that id; the removal only takes visible effect once
79
+ * the snapshot catches up.
80
+ */
81
+ export const mergeUIMessages = (
82
+ live: readonly UIMessage[],
83
+ snapshot: unknown,
84
+ ): UIMessage[] => {
85
+ const byId = new Map<string, UIMessage>();
86
+ for (const ui of live) byId.set(ui.id, ui);
87
+ if (Array.isArray(snapshot)) {
88
+ for (const ui of snapshot as UIMessage[]) byId.set(ui.id, ui);
89
+ }
90
+ return [...byId.values()];
91
+ };
@@ -22,7 +22,7 @@ vi.mock(import("@assistant-ui/store"), async (importOriginal) => {
22
22
  };
23
23
  });
24
24
 
25
- import { useLangChainError } from "./useStreamRuntime";
25
+ import { useLangChainError } from "./hooks";
26
26
 
27
27
  const runSelectorAgainst = createRunSelectorAgainst(mockUseAuiState);
28
28
 
@@ -0,0 +1,48 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { renderHook } from "@testing-library/react";
5
+ import {
6
+ createRunSelectorAgainst,
7
+ makeExtras,
8
+ type Selector,
9
+ } from "./__tests__/langChainTestUtils";
10
+
11
+ const { mockUseAuiState } = vi.hoisted(() => ({
12
+ mockUseAuiState: vi.fn(),
13
+ }));
14
+
15
+ vi.mock(import("@assistant-ui/store"), async (importOriginal) => {
16
+ const actual = await importOriginal();
17
+ return {
18
+ ...actual,
19
+ useAuiState: ((selector: Selector) =>
20
+ mockUseAuiState(selector)) as typeof actual.useAuiState,
21
+ useAui: (() => ({})) as unknown as typeof actual.useAui,
22
+ };
23
+ });
24
+
25
+ import { useLangChainInterrupts } from "./hooks";
26
+
27
+ const runSelectorAgainst = createRunSelectorAgainst(mockUseAuiState);
28
+
29
+ describe("useLangChainInterrupts", () => {
30
+ it("returns an empty array when extras are absent", () => {
31
+ runSelectorAgainst(undefined);
32
+ const { result } = renderHook(() => useLangChainInterrupts());
33
+ expect(result.current).toEqual([]);
34
+ });
35
+
36
+ it("returns the pending interrupts from extras", () => {
37
+ const interrupts = [{ id: "i1", value: { q: "ok?" } }];
38
+ runSelectorAgainst(makeExtras({ interrupts }));
39
+ const { result } = renderHook(() => useLangChainInterrupts());
40
+ expect(result.current).toBe(interrupts);
41
+ });
42
+
43
+ it("returns an empty array when extras carry no interrupts", () => {
44
+ runSelectorAgainst(makeExtras({ interrupts: undefined }));
45
+ const { result } = renderHook(() => useLangChainInterrupts());
46
+ expect(result.current).toEqual([]);
47
+ });
48
+ });
@@ -0,0 +1,49 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { renderHook } from "@testing-library/react";
5
+ import { makeExtras } from "./__tests__/langChainTestUtils";
6
+
7
+ const { mockUseAui } = vi.hoisted(() => ({
8
+ mockUseAui: vi.fn(),
9
+ }));
10
+
11
+ vi.mock(import("@assistant-ui/store"), async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return {
14
+ ...actual,
15
+ useAui: (() => mockUseAui()) as unknown as typeof actual.useAui,
16
+ };
17
+ });
18
+
19
+ import { useLangChainRespond } from "./hooks";
20
+
21
+ describe("useLangChainRespond", () => {
22
+ it("forwards the response and options to stream.respond", async () => {
23
+ const respond = vi.fn().mockResolvedValue(undefined);
24
+ mockUseAui.mockReturnValue({
25
+ thread: () => ({
26
+ getState: () => ({ extras: makeExtras({ respond }) }),
27
+ }),
28
+ });
29
+
30
+ const { result } = renderHook(() => useLangChainRespond());
31
+ await result.current({ approved: true }, { interruptId: "x" });
32
+
33
+ expect(respond).toHaveBeenCalledWith(
34
+ { approved: true },
35
+ { interruptId: "x" },
36
+ );
37
+ });
38
+
39
+ it("throws when extras are absent or unbranded", () => {
40
+ mockUseAui.mockReturnValue({
41
+ thread: () => ({ getState: () => ({ extras: { respond: vi.fn() } }) }),
42
+ });
43
+
44
+ const { result } = renderHook(() => useLangChainRespond());
45
+ expect(() => result.current({ approved: true })).toThrow(
46
+ "useStreamRuntime",
47
+ );
48
+ });
49
+ });
@@ -0,0 +1,52 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { renderHook } from "@testing-library/react";
5
+ import { makeExtras } from "./__tests__/langChainTestUtils";
6
+
7
+ const { mockUseAui } = vi.hoisted(() => ({
8
+ mockUseAui: vi.fn(),
9
+ }));
10
+
11
+ vi.mock(import("@assistant-ui/store"), async (importOriginal) => {
12
+ const actual = await importOriginal();
13
+ return {
14
+ ...actual,
15
+ useAui: (() => mockUseAui()) as unknown as typeof actual.useAui,
16
+ };
17
+ });
18
+
19
+ import { useLangChainRespondAll } from "./hooks";
20
+
21
+ describe("useLangChainRespondAll", () => {
22
+ it("forwards the responses and options to stream.respondAll", async () => {
23
+ const respondAll = vi.fn().mockResolvedValue(undefined);
24
+ mockUseAui.mockReturnValue({
25
+ thread: () => ({
26
+ getState: () => ({ extras: makeExtras({ respondAll }) }),
27
+ }),
28
+ });
29
+
30
+ const { result } = renderHook(() => useLangChainRespondAll());
31
+ await result.current(
32
+ { a: { approved: true }, b: { approved: false } },
33
+ { metadata: { src: "ui" } },
34
+ );
35
+
36
+ expect(respondAll).toHaveBeenCalledWith(
37
+ { a: { approved: true }, b: { approved: false } },
38
+ { metadata: { src: "ui" } },
39
+ );
40
+ });
41
+
42
+ it("throws when extras are absent or unbranded", () => {
43
+ mockUseAui.mockReturnValue({
44
+ thread: () => ({ getState: () => ({ extras: { respondAll: vi.fn() } }) }),
45
+ });
46
+
47
+ const { result } = renderHook(() => useLangChainRespondAll());
48
+ expect(() => result.current({ a: { approved: true } })).toThrow(
49
+ "useStreamRuntime",
50
+ );
51
+ });
52
+ });