@assistant-ui/react-langchain 0.0.14 → 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.
- package/README.md +1 -1
- package/dist/convertMessages.d.ts +37 -3
- package/dist/convertMessages.d.ts.map +1 -1
- package/dist/convertMessages.js +63 -11
- package/dist/convertMessages.js.map +1 -1
- package/dist/findForkCheckpointInHistory.d.ts +26 -0
- package/dist/findForkCheckpointInHistory.d.ts.map +1 -0
- package/dist/findForkCheckpointInHistory.js +34 -0
- package/dist/findForkCheckpointInHistory.js.map +1 -0
- package/dist/hooks.d.ts +88 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +99 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +4 -2
- package/dist/resolveForkCheckpoint.d.ts +17 -0
- package/dist/resolveForkCheckpoint.d.ts.map +1 -0
- package/dist/resolveForkCheckpoint.js +26 -0
- package/dist/resolveForkCheckpoint.js.map +1 -0
- package/dist/runtimeExtras.d.ts +7 -0
- package/dist/runtimeExtras.d.ts.map +1 -0
- package/dist/runtimeExtras.js +7 -0
- package/dist/runtimeExtras.js.map +1 -0
- package/dist/streamingTiming.d.ts +16 -0
- package/dist/streamingTiming.d.ts.map +1 -0
- package/dist/streamingTiming.js +53 -0
- package/dist/streamingTiming.js.map +1 -0
- package/dist/types.d.ts +113 -5
- package/dist/types.d.ts.map +1 -1
- package/dist/uiMessages.d.ts +24 -0
- package/dist/uiMessages.d.ts.map +1 -0
- package/dist/uiMessages.js +68 -0
- package/dist/uiMessages.js.map +1 -0
- package/dist/useStreamRuntime.d.ts +15 -76
- package/dist/useStreamRuntime.d.ts.map +1 -1
- package/dist/useStreamRuntime.js +190 -111
- package/dist/useStreamRuntime.js.map +1 -1
- package/package.json +9 -9
- package/src/__tests__/langChainTestUtils.ts +22 -0
- package/src/convertMessages.test.ts +210 -0
- package/src/convertMessages.ts +111 -11
- package/src/findForkCheckpointInHistory.test.ts +263 -0
- package/src/findForkCheckpointInHistory.ts +68 -0
- package/src/groupUIMessagesByParent.test.ts +61 -0
- package/src/hooks.ts +156 -0
- package/src/index.ts +20 -3
- package/src/resolveForkCheckpoint.test.ts +202 -0
- package/src/resolveForkCheckpoint.ts +49 -0
- package/src/runConfigToSubmitOptions.test.ts +24 -0
- package/src/runtimeExtras.ts +5 -0
- package/src/streamingTiming.test.ts +118 -0
- package/src/streamingTiming.ts +85 -0
- package/src/types.ts +147 -3
- package/src/uiMessages.test.ts +190 -0
- package/src/uiMessages.ts +91 -0
- package/src/useLangChainError.test.tsx +48 -0
- package/src/useLangChainInterrupts.test.tsx +48 -0
- package/src/useLangChainRespond.test.tsx +49 -0
- package/src/useLangChainRespondAll.test.tsx +52 -0
- package/src/useLangChainState.test.tsx +14 -26
- package/src/useLangChainStream.test.tsx +42 -0
- package/src/useLangChainSubagents.test.tsx +48 -0
- package/src/useLangChainSubgraphs.test.tsx +48 -0
- package/src/useLangChainToolCalls.test.tsx +48 -0
- package/src/useStreamRuntime.test.tsx +232 -0
- package/src/useStreamRuntime.ts +311 -234
|
@@ -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,39 @@
|
|
|
1
|
-
import type {
|
|
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
|
|
24
|
+
| { type: "image_url"; image_url: string | { url?: string } }
|
|
8
25
|
| { type: "thinking"; thinking: string }
|
|
9
26
|
| {
|
|
10
27
|
type: "reasoning";
|
|
11
|
-
summary
|
|
28
|
+
summary?: Array<{ type: "summary_text"; text?: string }>;
|
|
29
|
+
reasoning?: string;
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
type: "file";
|
|
33
|
+
data: string;
|
|
34
|
+
mime_type: string;
|
|
35
|
+
source_type?: "base64";
|
|
36
|
+
metadata?: { filename?: string };
|
|
12
37
|
}
|
|
13
38
|
| { type: "tool_use" | "input_json_delta" };
|
|
14
39
|
|
|
@@ -18,6 +43,41 @@ export type LangChainToolCall = {
|
|
|
18
43
|
args: Record<string, unknown>;
|
|
19
44
|
};
|
|
20
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
|
+
|
|
21
81
|
/**
|
|
22
82
|
* Minimal duck-typed interface for BaseMessage class instances returned by
|
|
23
83
|
* `useStream`. Used internally by the message converter.
|
|
@@ -40,3 +100,87 @@ export type LangChainBaseMessage = {
|
|
|
40
100
|
/** Present on ToolMessage */
|
|
41
101
|
artifact?: unknown;
|
|
42
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
|
+
};
|
|
@@ -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 { useLangChainError } from "./hooks";
|
|
26
|
+
|
|
27
|
+
const runSelectorAgainst = createRunSelectorAgainst(mockUseAuiState);
|
|
28
|
+
|
|
29
|
+
describe("useLangChainError", () => {
|
|
30
|
+
it("returns undefined when extras are absent", () => {
|
|
31
|
+
runSelectorAgainst(undefined);
|
|
32
|
+
const { result } = renderHook(() => useLangChainError());
|
|
33
|
+
expect(result.current).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns the error value when extras carry error", () => {
|
|
37
|
+
const error = new Error("boom");
|
|
38
|
+
runSelectorAgainst(makeExtras({ error }));
|
|
39
|
+
const { result } = renderHook(() => useLangChainError());
|
|
40
|
+
expect(result.current).toBe(error);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns undefined when extras carry no error", () => {
|
|
44
|
+
runSelectorAgainst(makeExtras({ error: undefined }));
|
|
45
|
+
const { result } = renderHook(() => useLangChainError());
|
|
46
|
+
expect(result.current).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -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
|
+
});
|