///
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { AppendMessage, ToolExecutionStatus } from "@assistant-ui/core";
import {
generateId,
getExternalStoreMessages,
pickExternalStoreSharedOptions,
} from "@assistant-ui/core";
import type { ThreadMessage } from "@assistant-ui/core";
import {
useCloudThreadListAdapter,
useExternalStoreRuntime,
useExternalMessageConverter,
useRemoteThreadListRuntime,
} from "@assistant-ui/core/react";
import { useAui, useAuiState } from "@assistant-ui/store";
import { STREAM_CONTROLLER, useChannel, useStream } from "@langchain/react";
import type { Channel } from "@langchain/react";
import type {
LangChainBaseMessage,
LangChainToolCall,
UIMessage,
UseStreamRuntimeOptions,
} from "./types";
import {
convertLangChainBaseMessage,
getMessageContent,
getMessageType,
} from "./convertMessages";
import { foldUIUpdates, mergeUIMessages } from "./uiMessages";
import { langChainExtras } from "./runtimeExtras";
import { resolveForkCheckpoint } from "./resolveForkCheckpoint";
import { useLangChainStreamingTiming } from "./streamingTiming";
const UI_CUSTOM_CHANNELS: readonly Channel[] = ["custom"];
export const runConfigToSubmitOptions = (
runConfig: AppendMessage["runConfig"],
) =>
runConfig?.custom
? { config: { configurable: runConfig.custom } }
: undefined;
type NormalizedRunConfigOptions = NonNullable<
ReturnType
>;
/**
* Group the graph's accumulated `UIMessage`s by the assistant message they
* belong to. Non-array state and entries without a parent link are dropped.
* The parent id comes from `metadata.message_id` (Python SDK) or
* `metadata.id` (JS SDK).
*/
export const groupUIMessagesByParent = (
value: unknown,
): Map => {
const map = new Map();
if (!Array.isArray(value)) return map;
for (const ui of value as UIMessage[]) {
const parentId = ui.metadata?.message_id ?? ui.metadata?.id;
if (!parentId) continue;
const existing = map.get(parentId);
if (existing) {
existing.push(ui);
} else {
map.set(parentId, [ui]);
}
}
return map;
};
const getPendingToolCalls = (
messages: readonly LangChainBaseMessage[],
): LangChainToolCall[] => {
const pending = new Map();
for (const m of messages) {
const type = getMessageType(m);
if (type === "ai") {
for (const tc of m.tool_calls ?? []) pending.set(tc.id, tc);
} else if (type === "tool" && m.tool_call_id) {
pending.delete(m.tool_call_id);
}
}
return [...pending.values()];
};
const toStagedHumanMessage = (
msg: AppendMessage,
id = generateId(),
): LangChainBaseMessage & { id: string } => ({
id,
_getType: () => "human",
content: getMessageContent(msg),
});
const humanContentText = (content: LangChainBaseMessage["content"]) => {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.filter(
(part): part is { type: "text"; text: string } =>
typeof part === "object" &&
part !== null &&
part.type === "text" &&
typeof part.text === "string",
)
.map((part) => part.text)
.join("");
};
const hasSameMessageContent = (
a: LangChainBaseMessage,
b: LangChainBaseMessage,
) => humanContentText(a.content) === humanContentText(b.content);
const truncateLangChainBaseMessages = (
threadMessages: readonly ThreadMessage[],
parentId: string | null,
): LangChainBaseMessage[] => {
if (parentId === null) return [];
const parentIndex = threadMessages.findIndex((m) => m.id === parentId);
if (parentIndex === -1) return [];
const truncated: LangChainBaseMessage[] = [];
for (let i = 0; i <= parentIndex && i < threadMessages.length; i++) {
truncated.push(
...getExternalStoreMessages(threadMessages[i]!),
);
}
return truncated;
};
type DistributiveOmit = T extends unknown
? Omit
: never;
const useStreamThreadRuntime = (
options: DistributiveOmit<
UseStreamRuntimeOptions,
"cloud" | "unstable_threadListAdapter" | "create" | "delete"
>,
) => {
const { adapters, autoCancelPendingToolCalls, unstable_allowCancellation } =
options;
const aui = useAui();
const messagesKey = options.messagesKey ?? "messages";
const uiStateKey = options.uiStateKey ?? "ui";
const externalId = useAuiState((s) => s.threadListItem.externalId) as
| string
| null;
// Object.assign preserves the discriminated transport union; object spread
// collapses its arms and no longer satisfies UseStreamOptions.
const stream = useStream(
Object.assign({}, options, { threadId: externalId }),
);
const [stagedMessages, setStagedMessages] = useState<
LangChainBaseMessage[] | null
>(null);
const [toolStatuses, setToolStatuses] = useState<
Record
>({});
const hasExecutingTools = Object.values(toolStatuses).some(
(s) => s?.type === "executing",
);
const effectiveIsRunning = stream.isLoading || hasExecutingTools;
const uiStateValue = stream.values[uiStateKey];
const customEvents = useChannel(stream, UI_CUSTOM_CHANNELS);
const liveUiMessages = useMemo(
() => foldUIUpdates(customEvents),
[customEvents],
);
const mergedUiMessages = useMemo(
() => mergeUIMessages(liveUiMessages, uiStateValue),
[liveUiMessages, uiStateValue],
);
const visibleMessages =
stagedMessages ?? (stream.messages as LangChainBaseMessage[]);
const messageTiming = useLangChainStreamingTiming(
visibleMessages,
effectiveIsRunning,
);
const convertWithUI = useMemo<
useExternalMessageConverter.Callback
>(() => {
const uiMessagesByParent = groupUIMessagesByParent(mergedUiMessages);
return (message, metadata) =>
convertLangChainBaseMessage(message, {
...metadata,
uiMessagesByParent,
messageTiming,
});
}, [mergedUiMessages, messageTiming]);
const threadMessages = useExternalMessageConverter({
callback: convertWithUI,
messages: visibleMessages,
isRunning: effectiveIsRunning,
});
const streamRef = useRef(stream);
streamRef.current = stream;
const activeRunConfigRef = useRef<
NormalizedRunConfigOptions["config"] | undefined
>(undefined);
const runConfigByMessageIdRef = useRef(
new Map(),
);
const activeThreadIdRef = useRef(externalId);
const setActiveRunConfig = useCallback(
(runConfig: AppendMessage["runConfig"]) => {
activeRunConfigRef.current = runConfigToSubmitOptions(runConfig)?.config;
},
[],
);
const withActiveRunConfig = useCallback(
(submitOptions?: Record) => {
if (submitOptions && "config" in submitOptions) return submitOptions;
if (activeRunConfigRef.current === undefined) return submitOptions;
return { ...submitOptions, config: activeRunConfigRef.current };
},
[],
);
useEffect(() => {
if (
activeThreadIdRef.current !== null &&
activeThreadIdRef.current !== externalId
) {
activeRunConfigRef.current = undefined;
runConfigByMessageIdRef.current.clear();
}
activeThreadIdRef.current = externalId;
}, [externalId]);
useEffect(() => {
const messages = stream.messages as readonly LangChainBaseMessage[];
const owned = runConfigByMessageIdRef.current;
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages.at(i);
if (
!message?.id ||
getMessageType(message) !== "ai" ||
!message.tool_calls?.length
) {
continue;
}
if (owned.has(message.id)) return;
break;
}
for (const message of messages) {
if (
!message.id ||
getMessageType(message) !== "ai" ||
!message.tool_calls?.length ||
owned.has(message.id)
) {
continue;
}
owned.set(message.id, activeRunConfigRef.current);
}
}, [stream.messages]);
const visibleMessagesRef = useRef(visibleMessages);
visibleMessagesRef.current = visibleMessages;
const threadMessagesRef = useRef(threadMessages);
threadMessagesRef.current = threadMessages;
const stagedMessagesRef = useRef(
new Map<
string,
{
message: LangChainBaseMessage & { id: string };
runConfig: AppendMessage["runConfig"];
reconcileOnEcho: boolean;
baseMessageCount: number;
}
>(),
);
const stagedBaseMessagesRef = useRef(null);
useEffect(() => {
if (stagedMessagesRef.current.size === 0) return;
// Staged edits must keep their truncated base while stream updates arrive before promotion.
const baseMessages =
stagedBaseMessagesRef.current ??
(stream.messages as LangChainBaseMessage[]);
const remainingStagedMessages: LangChainBaseMessage[] = [];
const matchedBaseMessageIndexes = new Set();
const visibleStagedIds = new Set(
visibleMessagesRef.current.flatMap((m) => (m.id ? [m.id] : [])),
);
for (const [id, staged] of stagedMessagesRef.current) {
if (!visibleStagedIds.has(id)) continue;
const echoed = baseMessages.some((message, index) => {
if (matchedBaseMessageIndexes.has(index)) return false;
if (message.id === id) {
matchedBaseMessageIndexes.add(index);
return true;
}
if (
!staged.reconcileOnEcho ||
index < staged.baseMessageCount ||
getMessageType(message) !== "human" ||
!hasSameMessageContent(message, staged.message)
) {
return false;
}
matchedBaseMessageIndexes.add(index);
return true;
});
if (echoed) stagedMessagesRef.current.delete(id);
else remainingStagedMessages.push(staged.message);
}
if (remainingStagedMessages.length === 0) {
stagedBaseMessagesRef.current = null;
visibleMessagesRef.current = baseMessages;
setStagedMessages(null);
return;
}
const nextMessages = [...baseMessages, ...remainingStagedMessages];
visibleMessagesRef.current = nextMessages;
setStagedMessages(nextMessages);
}, [stream.messages]);
const getStagedRun = (parentId: string | null) => {
if (!parentId || !stagedMessagesRef.current.has(parentId)) return null;
const staged: LangChainBaseMessage[] = [];
for (const message of visibleMessagesRef.current) {
if (message.id && stagedMessagesRef.current.has(message.id)) {
staged.push(stagedMessagesRef.current.get(message.id)!.message);
}
if (message.id === parentId) break;
}
return {
messages: staged,
runConfig: stagedMessagesRef.current.get(parentId)!.runConfig,
};
};
const stageUserMessage = (msg: AppendMessage, reconcileOnEcho = false) => {
const stagedMessage = toStagedHumanMessage(msg);
stagedMessagesRef.current.set(stagedMessage.id, {
message: stagedMessage,
runConfig: msg.runConfig,
reconcileOnEcho,
baseMessageCount: streamRef.current.messages.length,
});
const nextMessages = [...visibleMessagesRef.current, stagedMessage];
visibleMessagesRef.current = nextMessages;
setStagedMessages(nextMessages);
return stagedMessage;
};
const removeStagedMessage = (id: string) => {
if (!stagedMessagesRef.current.delete(id)) return;
const nextMessages = visibleMessagesRef.current.filter(
(message) => message.id !== id,
);
visibleMessagesRef.current = nextMessages;
if (stagedMessagesRef.current.size === 0) {
stagedBaseMessagesRef.current = null;
setStagedMessages(null);
} else {
setStagedMessages(nextMessages);
}
};
const extras = useMemo(
() =>
langChainExtras.provide({
interrupt: stream.interrupt,
interrupts: stream.interrupts,
toolCalls: stream.toolCalls,
subagents: stream.subagents,
subgraphs: stream.subgraphs,
stream,
error: stream.error,
submit: (values, submitOptions) => {
const isResume = values == null || submitOptions?.command != null;
return stream.submit(
values,
isResume ? withActiveRunConfig(submitOptions) : submitOptions,
);
},
respond: (response, respondOptions) =>
stream.respond(response, withActiveRunConfig(respondOptions)),
respondAll: (responsesById, respondOptions) =>
stream.respondAll(responsesById, withActiveRunConfig(respondOptions)),
values: stream.values,
messagesKey,
}),
[stream, messagesKey, withActiveRunConfig],
);
const runtime = useExternalStoreRuntime({
...pickExternalStoreSharedOptions(options),
isRunning: effectiveIsRunning,
isLoading: stream.isThreadLoading,
messages: threadMessages,
adapters,
extras,
unstable_enableToolInvocations: true,
setToolStatuses,
onNew: async (msg) => {
if (!(msg.startRun ?? msg.role === "user")) {
stageUserMessage(msg);
return;
}
const stagedMessage = stageUserMessage(msg, true);
const stagedMessageId = stagedMessage.id;
setActiveRunConfig(msg.runConfig);
const content = getMessageContent(msg);
const cancellations =
autoCancelPendingToolCalls !== false
? getPendingToolCalls(
streamRef.current.messages as readonly LangChainBaseMessage[],
).map((t) => ({
type: "tool" as const,
name: t.name,
tool_call_id: t.id,
content: JSON.stringify({ cancelled: true }),
status: "error" as const,
}))
: [];
// A null threadId is not a no-op for the SDK: it rebinds the controller
// away from its self-created thread and forces a fresh one, so the
// submit waits for initialization to produce an identity; core no
// longer holds appends on that barrier.
try {
const { externalId } = await aui.threadListItem.initialize();
await streamRef.current.submit(
{
[messagesKey]: [
...cancellations,
{
id: stagedMessageId,
type: "human",
content,
},
],
},
{
...runConfigToSubmitOptions(msg.runConfig),
...(externalId != null ? { threadId: externalId } : {}),
},
);
} catch (error) {
removeStagedMessage(stagedMessageId);
throw error;
}
},
onAddToolResult: async ({
messageId,
toolCallId,
toolName,
result,
isError,
artifact,
}) => {
const runConfig = runConfigByMessageIdRef.current.has(messageId)
? runConfigByMessageIdRef.current.get(messageId)
: activeRunConfigRef.current;
await stream.submit(
{
[messagesKey]: [
{
type: "tool",
name: toolName,
tool_call_id: toolCallId,
content: JSON.stringify(result),
...(artifact !== undefined && { artifact }),
status: isError ? "error" : "success",
},
],
},
runConfig === undefined ? undefined : { config: runConfig },
);
},
onReload: async (parentId, config) => {
const stagedRun = getStagedRun(parentId);
if (stagedRun) {
const promotedIds = new Set();
for (const message of stagedRun.messages) {
if (!message.id) continue;
promotedIds.add(message.id);
stagedMessagesRef.current.delete(message.id);
}
stagedBaseMessagesRef.current = null;
if (stagedMessagesRef.current.size > 0) {
const nextMessages = visibleMessagesRef.current.filter(
(message) => !message.id || !promotedIds.has(message.id),
);
visibleMessagesRef.current = nextMessages;
setStagedMessages(nextMessages);
} else {
setStagedMessages(null);
}
const runConfig = config.runConfig ?? stagedRun.runConfig;
setActiveRunConfig(runConfig);
await stream.submit(
{
[messagesKey]: stagedRun.messages.map((message) => ({
id: message.id,
type: "human",
content: message.content,
})),
},
runConfigToSubmitOptions(runConfig),
);
return;
}
const threadId = externalId;
if (!threadId || parentId == null) return;
const s = streamRef.current;
const checkpointId = await resolveForkCheckpoint(
s.client,
threadId,
s.messages as readonly LangChainBaseMessage[],
parentId,
config.sourceId,
s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
messagesKey,
);
if (!checkpointId) return;
setActiveRunConfig(config.runConfig);
await s.submit(null, {
forkFrom: checkpointId,
...runConfigToSubmitOptions(config.runConfig),
});
},
onEdit: async (message) => {
if (!(message.startRun ?? message.role === "user")) {
const truncated = truncateLangChainBaseMessages(
threadMessagesRef.current,
message.parentId,
);
const stagedMessage = toStagedHumanMessage(message);
stagedMessagesRef.current.set(stagedMessage.id, {
message: stagedMessage,
runConfig: message.runConfig,
reconcileOnEcho: false,
baseMessageCount: 0,
});
stagedBaseMessagesRef.current = truncated;
const nextMessages = [...truncated, stagedMessage];
visibleMessagesRef.current = nextMessages;
setStagedMessages(nextMessages);
return;
}
const threadId = externalId;
if (!threadId) return;
const s = streamRef.current;
const checkpointId = await resolveForkCheckpoint(
s.client,
threadId,
s.messages as readonly LangChainBaseMessage[],
message.parentId,
message.sourceId,
s[STREAM_CONTROLLER]?.messageMetadataStore?.getSnapshot?.(),
messagesKey,
);
if (!checkpointId) return;
const content = getMessageContent(message);
setActiveRunConfig(message.runConfig);
await s.submit(
{ [messagesKey]: [{ type: "human", content }] },
{
forkFrom: checkpointId,
...runConfigToSubmitOptions(message.runConfig),
},
);
},
onCancel:
unstable_allowCancellation !== false
? async () => {
activeRunConfigRef.current = undefined;
await stream.stop();
}
: undefined,
});
return runtime;
};
/**
* Creates an assistant-ui runtime backed by LangChain's `useStream` hook.
* Accepts the same options as `useStream` from `@langchain/react`, plus
* `cloud` and `adapters`.
*
* @example
* ```tsx
* import { useStreamRuntime } from "@assistant-ui/react-langchain";
* import { AssistantRuntimeProvider, Thread } from "@assistant-ui/react";
*
* function App() {
* const runtime = useStreamRuntime({
* assistantId: "agent",
* apiUrl: "http://localhost:2024",
* });
*
* return (
*
*
*
* );
* }
* ```
*/
export const useStreamRuntime = (rawOptions: UseStreamRuntimeOptions) => {
const {
cloud,
unstable_threadListAdapter,
create,
delete: deleteFn,
onThreadIdChange,
...options
} = rawOptions;
const optionsRef = useRef(options);
optionsRef.current = options;
const cloudAdapter = useCloudThreadListAdapter({
cloud,
create,
delete: deleteFn,
});
const adapter = unstable_threadListAdapter ?? cloudAdapter;
return useRemoteThreadListRuntime({
runtimeHook: function RuntimeHook() {
return useStreamThreadRuntime(optionsRef.current);
},
adapter,
allowNesting: true,
onThreadIdChange,
});
};