agents
Version:
A home for your AI agents
1,166 lines (1,165 loc) • 49.6 kB
JavaScript
import { n as STREAM_RESUME_NONE_REASONS, r as transition } from "../protocol-B0nh6KNf.js";
import "../wire-types-CnMt6_HR.js";
import { t as WebSocketChatTransport } from "../ws-chat-transport-rWwta645.js";
import { getToolName, isToolUIPart } from "ai";
import { nanoid } from "nanoid";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
/**
* Picks the throttle from an explicit caller value, the deprecated alias, or
* the default — in that order. `false` turns throttling off.
*/
function resolveChatThrottleMs(options) {
if (options.throttle === false) return void 0;
return options.throttle ?? options.experimental_throttle ?? 50;
}
/**
* The throttle spelled under both option names, or neither name when it is off.
*
* The two names are not interchangeable across the peer range. `@ai-sdk/react`
* v3 only reads `experimental_throttle`; v4 renamed it to `throttle` and reads
* `throttle ?? experimental_throttle`. Our peer range allows both majors, so
* sending one name would silently do nothing on the other. Unknown option keys
* are ignored by both, so sending both names is safe.
*
* Turning throttling off omits both names rather than inventing a numeric
* value. Both majors also treat an explicit `0` as unthrottled, so callers that
* pass `0` keep that value under both spellings.
*/
function chatThrottleOptions(options) {
const ms = resolveChatThrottleMs(options);
if (ms === void 0) return {};
return {
experimental_throttle: ms,
throttle: ms
};
}
//#endregion
//#region src/chat/react.tsx
/**
* One-shot deprecation warnings (warns once per key per session).
*/
const _deprecationWarnings = /* @__PURE__ */ new Set();
function warnDeprecated(id, message) {
if (!_deprecationWarnings.has(id)) {
_deprecationWarnings.add(id);
console.warn(`[agents/chat] Deprecated: ${message}`);
}
}
/**
* Extracts tool schemas from tools that have client-side execute functions.
* These schemas are automatically sent to the server with each request.
*
* Called internally by `useAgentChat` when `tools` are provided.
* Most apps do not need to call this directly.
*
* @param tools - Record of tool name to tool definition
* @returns Array of tool schemas to send to server, or undefined if none
*/
function extractClientToolSchemas(tools) {
if (!tools) return void 0;
const schemas = Object.entries(tools).filter(([_, tool]) => tool.execute).map(([name, tool]) => {
if (tool.inputSchema && !tool.parameters) console.warn(`[useAgentChat] Tool "${name}" uses deprecated 'inputSchema'. Please migrate to 'parameters'.`);
return {
name,
description: tool.description,
parameters: tool.parameters ?? tool.inputSchema
};
});
return schemas.length > 0 ? schemas : void 0;
}
/**
* Map internal tool part states to simplified UI-relevant states.
*
* @example
* ```tsx
* import { isToolUIPart } from "ai";
* import { getToolPartState } from "@cloudflare/ai-chat/react";
*
* if (isToolUIPart(part)) {
* const state = getToolPartState(part);
* if (state === "complete") { ... }
* if (state === "waiting-approval") { ... }
* }
* ```
*/
function getToolPartState(part) {
switch (part.state) {
case "input-streaming": return "streaming";
case "approval-requested": return "waiting-approval";
case "approval-responded": return "approved";
case "output-available": return "complete";
case "output-error": return "error";
case "output-denied": return "denied";
default: return "loading";
}
}
/** Get the tool call ID from a tool UI part. */
function getToolCallId(part) {
return part.toolCallId;
}
/** Get the tool input from a tool UI part (if available). */
function getToolInput(part) {
return part.input;
}
/** Get the tool output from a tool UI part (if available). */
function getToolOutput(part) {
return part.output;
}
/** Get the approval info from a tool UI part (if in approval state). */
function getToolApproval(part) {
return part.approval;
}
function agentNameToKebab(name) {
if (name === name.toUpperCase() && name !== name.toLowerCase()) return name.toLowerCase().replace(/_/g, "-");
let result = name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
result = result.startsWith("-") ? result.slice(1) : result;
return result.replace(/_/g, "-").replace(/-$/, "");
}
/**
* Fetch messages from an agent's `/get-messages` HTTP endpoint.
*
* Use in framework route loaders to prefetch messages before the component
* tree mounts, or anywhere you need messages outside a React hook.
*
* @example Standard routing
* ```typescript
* import { getAgentMessages } from "@cloudflare/ai-chat/react";
*
* const messages = await getAgentMessages({
* host: "https://my-app.workers.dev",
* agent: "ChatAgent",
* name: "session-123"
* });
* ```
*
* @example With basePath (custom URL)
* ```typescript
* const messages = await getAgentMessages({
* url: "https://my-app.workers.dev/custom/path/get-messages"
* });
* ```
*/
async function getAgentMessages(options) {
let messagesUrl;
if ("url" in options) messagesUrl = options.url;
else {
const agentSlug = agentNameToKebab(options.agent);
messagesUrl = `${options.host.endsWith("/") ? options.host.slice(0, -1) : options.host}/agents/${agentSlug}/${options.name}/get-messages`;
}
try {
const response = await fetch(messagesUrl, {
credentials: options.credentials,
headers: options.headers
});
if (!response.ok) {
console.warn(`[getAgentMessages] Failed to fetch: ${response.status} ${response.statusText}`);
return [];
}
const text = await response.text();
if (!text.trim()) return [];
return JSON.parse(text);
} catch (error) {
console.warn("[getAgentMessages] Fetch error:", error);
return [];
}
}
/**
* Module-level cache for initial message fetches. Intentionally shared across
* all useAgentChat instances to deduplicate requests during React Strict Mode
* double-renders and re-renders. Cache keys include the agent URL, agent type,
* and thread name to prevent cross-agent collisions.
*/
const requestCache = /* @__PURE__ */ new Map();
function findLastAssistantMessage(messages) {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant") return {
index,
message
};
}
return null;
}
function moveMessageToEnd(messages, messageId) {
const idx = messages.findIndex((m) => m.id === messageId);
if (idx < 0 || idx === messages.length - 1) return messages;
const result = [...messages];
const [msg] = result.splice(idx, 1);
if (!msg) return messages;
result.push(msg);
return result;
}
function prependMissingHydratedMessages(hydratedMessages, currentMessages) {
if (currentMessages.length === 0) return hydratedMessages;
const currentMessageIds = new Set(currentMessages.map((message) => message.id));
const missingHydratedMessages = hydratedMessages.filter((message) => !currentMessageIds.has(message.id));
if (missingHydratedMessages.length === 0) return currentMessages;
return [...missingHydratedMessages, ...currentMessages];
}
/**
* React hook for building AI chat interfaces using an Agent
* @param options Chat options including the agent connection
* @returns Chat interface controls and state with added clearHistory method
*/
/**
* Automatically detects which tools require confirmation based on their configuration.
* Tools require confirmation if they have no execute function AND are not server-executed.
* @param tools - Record of tool name to tool definition
* @returns Array of tool names that require confirmation
*
* @deprecated Use `needsApproval` on server-side tools instead.
*/
function detectToolsRequiringConfirmation(tools) {
warnDeprecated("detectToolsRequiringConfirmation", "detectToolsRequiringConfirmation() is deprecated. Use needsApproval on server-side tools instead. Will be removed in the next major version.");
if (!tools) return [];
return Object.entries(tools).filter(([_name, tool]) => !tool.execute).map(([name]) => name);
}
function useAgentChat(options) {
const { agent, getInitialMessages, messages: optionsInitialMessages, onToolCall, onData, experimental_automaticToolResolution, tools, toolsRequiringConfirmation: manualToolsRequiringConfirmation, autoContinueAfterToolResult = true, autoSendAfterAllConfirmationsResolved = true, resume = true, cancelOnClientAbort = false, syncMessagesToServer = true, body: bodyOption, prepareSendMessagesRequest, throttle, experimental_throttle, ...rest } = options;
if (manualToolsRequiringConfirmation) warnDeprecated("useAgentChat.toolsRequiringConfirmation", "The 'toolsRequiringConfirmation' option is deprecated. Use needsApproval on server-side tools instead. Will be removed in the next major version.");
if (experimental_automaticToolResolution) warnDeprecated("useAgentChat.experimental_automaticToolResolution", "The 'experimental_automaticToolResolution' option is deprecated. Use the onToolCall callback instead. Will be removed in the next major version.");
if (options.autoSendAfterAllConfirmationsResolved !== void 0) warnDeprecated("useAgentChat.autoSendAfterAllConfirmationsResolved", "The 'autoSendAfterAllConfirmationsResolved' option is deprecated. Use sendAutomaticallyWhen from AI SDK instead. Will be removed in the next major version.");
const toolsRequiringConfirmation = useMemo(() => {
if (manualToolsRequiringConfirmation) return manualToolsRequiringConfirmation;
if (!tools) return [];
return Object.entries(tools).filter(([_name, tool]) => !tool.execute).map(([name]) => name);
}, [manualToolsRequiringConfirmation, tools]);
const onToolCallRef = useRef(onToolCall);
onToolCallRef.current = onToolCall;
const onDataRef = useRef(onData);
onDataRef.current = onData;
const rawHttpUrl = agent.getHttpUrl();
const agentUrl = rawHttpUrl ? new URL(rawHttpUrl) : null;
if (agentUrl) agentUrl.searchParams.delete("_pk");
const agentUrlString = agentUrl?.toString() ?? null;
const agentAddressKey = Array.isArray(agent.path) ? JSON.stringify(agent.path.map((step) => [step.agent, step.name])) : JSON.stringify([[agent.agent ?? "", agent.name ?? ""]]);
const resolvedInitialMessagesCacheKey = agentUrl ? `${agentUrl.origin}${agentUrl.pathname}|${agentAddressKey}` : null;
const initialMessagesCacheKey = agentAddressKey;
const stableChatIdRef = useRef(null);
const previousAgentRef = useRef(null);
const previousAgentAddressKeyRef = useRef(null);
const fallbackChatId = agentAddressKey;
const agentPathChanged = Array.isArray(agent.path) && previousAgentAddressKeyRef.current !== null && previousAgentAddressKeyRef.current !== agentAddressKey;
if (stableChatIdRef.current === null) stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId;
else if (previousAgentRef.current !== agent || agentPathChanged) stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId;
previousAgentRef.current = agent;
previousAgentAddressKeyRef.current = agentAddressKey;
const agentRef = useRef(agent);
agentRef.current = agent;
async function defaultGetInitialMessagesFetch({ url }) {
if (!url) return [];
const getMessagesUrl = new URL(url);
getMessagesUrl.pathname += "/get-messages";
const response = await fetch(getMessagesUrl.toString(), {
credentials: options.credentials,
headers: options.headers
});
if (!response.ok) {
console.warn(`Failed to fetch initial messages: ${response.status} ${response.statusText}`);
return [];
}
const text = await response.text();
if (!text.trim()) return [];
try {
return JSON.parse(text);
} catch (error) {
console.warn("Failed to parse initial messages JSON:", error);
return [];
}
}
const getInitialMessagesFetch = getInitialMessages || defaultGetInitialMessagesFetch;
function doGetInitialMessages(getInitialMessagesOptions, cacheKey) {
if (requestCache.has(cacheKey)) return requestCache.get(cacheKey);
const promise = getInitialMessagesFetch(getInitialMessagesOptions);
requestCache.set(cacheKey, promise);
return promise;
}
const initialMessagesPromise = !(getInitialMessages === null ? false : getInitialMessages ? true : !!agentUrlString) ? null : doGetInitialMessages({
agent: agent.agent,
name: agent.name,
url: agentUrlString ?? void 0
}, initialMessagesCacheKey);
const initialMessages = initialMessagesPromise ? use(initialMessagesPromise) : optionsInitialMessages ?? [];
useEffect(() => {
if (!initialMessagesPromise) return;
requestCache.set(initialMessagesCacheKey, initialMessagesPromise);
return () => {
if (requestCache.get(initialMessagesCacheKey) === initialMessagesPromise) requestCache.delete(initialMessagesCacheKey);
};
}, [initialMessagesCacheKey, initialMessagesPromise]);
const toolsRef = useRef(tools);
toolsRef.current = tools;
const prepareSendMessagesRequestRef = useRef(prepareSendMessagesRequest);
prepareSendMessagesRequestRef.current = prepareSendMessagesRequest;
const bodyOptionRef = useRef(bodyOption);
bodyOptionRef.current = bodyOption;
/**
* Tracks request IDs initiated by this tab via the transport.
* Used by onAgentMessage to skip messages already handled by the transport.
*/
const localRequestIdsRef = useRef(/* @__PURE__ */ new Set());
const pendingReplayResumeRequestIdsRef = useRef(/* @__PURE__ */ new Set());
const replayHydratedAssistantMessageIdsRef = useRef(/* @__PURE__ */ new Set());
/**
* Request ids this socket already ACKed via the fallback resume path.
* The server sends CF_AGENT_STREAM_RESUMING for the same request from
* both onConnect and its CF_AGENT_STREAM_RESUME_REQUEST handler (#1733).
* The transport-handled path dedupes the second notify via
* localRequestIdsRef, but the fallback path used to ACK both — triggering
* a second full-buffer replay that duplicated streamed parts. Entries are
* dropped when the turn completes; the whole set resets when the socket
* closes, since a new connection legitimately needs a fresh ACK+replay.
*/
const fallbackAckedResumeRequestIdsRef = useRef(/* @__PURE__ */ new Set());
const customTransportRef = useRef(null);
if (customTransportRef.current === null) customTransportRef.current = new WebSocketChatTransport({
agent: agentRef.current,
activeRequestIds: localRequestIdsRef.current,
cancelOnClientAbort,
prepareBody: async ({ messages: msgs, trigger, messageId }) => {
let extraBody = {};
const currentBody = bodyOptionRef.current;
if (currentBody) extraBody = { ...typeof currentBody === "function" ? await currentBody() : currentBody };
if (toolsRef.current) {
const clientToolSchemas = extractClientToolSchemas(toolsRef.current);
if (clientToolSchemas) extraBody.clientTools = clientToolSchemas;
}
if (prepareSendMessagesRequestRef.current) {
const userResult = await prepareSendMessagesRequestRef.current({
id: agentRef.current._pk,
messages: msgs,
trigger,
messageId
});
if (userResult.body) Object.assign(extraBody, userResult.body);
}
return extraBody;
}
});
customTransportRef.current.setAgent(agentRef.current);
customTransportRef.current.setCancelOnClientAbort(cancelOnClientAbort);
const customTransport = customTransportRef.current;
const useChatHelpers = useChat({
...rest,
...chatThrottleOptions({
experimental_throttle,
throttle
}),
onData,
messages: initialMessages,
transport: customTransport,
id: stableChatIdRef.current,
resume: false
});
const { messages: chatMessages, setMessages, addToolResult, addToolApprovalResponse, sendMessage, resumeStream: rawResumeStream, status, stop } = useChatHelpers;
const statusRef = useRef(status);
statusRef.current = status;
const resumeGenerationRef = useRef(0);
const resumeOperationRef = useRef(null);
const reconnectProbePendingRef = useRef(false);
const reconnectProbeRunnerRef = useRef(null);
const invalidateResumeGeneration = useCallback(() => {
resumeGenerationRef.current++;
resumeOperationRef.current = null;
}, []);
const resumeStream = useCallback((...args) => {
const active = resumeOperationRef.current;
if (active) return active.promise;
const operation = {
generation: resumeGenerationRef.current,
promise: Promise.resolve()
};
resumeOperationRef.current = operation;
operation.promise = rawResumeStream(...args).finally(() => {
if (resumeOperationRef.current !== operation || resumeGenerationRef.current !== operation.generation) return;
resumeOperationRef.current = null;
reconnectProbeRunnerRef.current?.();
});
return operation.promise;
}, [rawResumeStream]);
const resumeStreamRef = useRef(resumeStream);
resumeStreamRef.current = resumeStream;
const resumingToolContinuationRef = useRef(false);
const pendingToolContinuationRef = useRef(false);
const observedToolContinuationRequestIdRef = useRef(null);
const continuationLaunchTimerRef = useRef(null);
const continuationGenerationRef = useRef(0);
const [isToolContinuation, setIsToolContinuation] = useState(false);
const resetToolContinuation = useCallback(() => {
continuationGenerationRef.current++;
pendingToolContinuationRef.current = false;
resumingToolContinuationRef.current = false;
observedToolContinuationRequestIdRef.current = null;
if (continuationLaunchTimerRef.current) {
clearTimeout(continuationLaunchTimerRef.current);
continuationLaunchTimerRef.current = null;
}
setIsToolContinuation(false);
}, []);
const scheduleToolContinuationLaunch = useCallback(() => {
if (!pendingToolContinuationRef.current || statusRef.current !== "ready" || continuationLaunchTimerRef.current) return;
const timer = setTimeout(() => {
(async () => {
while (resumeOperationRef.current) await resumeOperationRef.current.promise.catch(() => {});
if (continuationLaunchTimerRef.current === timer) continuationLaunchTimerRef.current = null;
if (!pendingToolContinuationRef.current || statusRef.current !== "ready") return;
pendingToolContinuationRef.current = false;
const myGeneration = continuationGenerationRef.current;
customTransport.expectToolContinuation();
await resumeStream().catch((error) => {
console.error("[useAgentChat] Tool continuation resume failed:", error);
}).finally(() => {
if (continuationGenerationRef.current !== myGeneration) return;
resumingToolContinuationRef.current = false;
setIsToolContinuation(false);
});
})();
}, 0);
continuationLaunchTimerRef.current = timer;
}, [customTransport, resumeStream]);
const startToolContinuation = useCallback(() => {
if (!autoContinueAfterToolResult || resumingToolContinuationRef.current) return;
++continuationGenerationRef.current;
resumingToolContinuationRef.current = true;
pendingToolContinuationRef.current = true;
setIsToolContinuation(true);
scheduleToolContinuationLaunch();
}, [autoContinueAfterToolResult, scheduleToolContinuationLaunch]);
useEffect(() => {
if (status === "error" && pendingToolContinuationRef.current) {
resetToolContinuation();
return;
}
scheduleToolContinuationLaunch();
}, [
resetToolContinuation,
scheduleToolContinuationLaunch,
status
]);
const stopWithToolContinuationAbort = useCallback(async () => {
try {
customTransport.cancelActiveServerTurn();
await stop();
} finally {
customTransport.abortActiveToolContinuation();
}
}, [stop, customTransport]);
const processedToolCalls = useRef(/* @__PURE__ */ new Set());
const isResolvingToolsRef = useRef(false);
const [toolResolutionTrigger, setToolResolutionTrigger] = useState(0);
const [clientToolResults, setClientToolResults] = useState(/* @__PURE__ */ new Map());
const initialMessagesRef = useRef(initialMessages);
initialMessagesRef.current = initialMessages;
const seededInitialMessagesKeyRef = useRef(null);
const markInitialMessagesSeeded = useCallback(() => {
seededInitialMessagesKeyRef.current = initialMessagesCacheKey;
}, [initialMessagesCacheKey]);
useEffect(() => {
if (!initialMessagesPromise) return;
if (seededInitialMessagesKeyRef.current === initialMessagesCacheKey) return;
markInitialMessagesSeeded();
setMessages((prevMessages) => prependMissingHydratedMessages(initialMessagesRef.current, prevMessages));
}, [
initialMessagesCacheKey,
initialMessagesPromise,
markInitialMessagesSeeded,
setMessages
]);
const localResponseMessageIdsRef = useRef(/* @__PURE__ */ new Map());
const protectedStreamingAssistantRef = useRef(null);
const preserveProtectedStreamingAssistant = useCallback((messages, currentMessages) => {
const protection = protectedStreamingAssistantRef.current;
if (!protection) return [...messages];
const protectedIndex = messages.findIndex((message) => message.id === protection.assistantId);
if (protectedIndex >= 0 && messages.slice(protectedIndex + 1).some((message) => message.role === "assistant")) {
protectedStreamingAssistantRef.current = null;
return [...messages];
}
const protectedAssistant = currentMessages.find((message) => message.id === protection.assistantId) ?? messages.find((message) => message.id === protection.assistantId);
if (!protectedAssistant) return [...messages];
return [...messages.filter((message) => message.id !== protection.assistantId), protectedAssistant];
}, []);
const protectStreamingAssistantTail = useCallback(() => {
if (statusRef.current !== "streaming") return;
setMessages((currentMessages) => {
const assistantInfo = findLastAssistantMessage(currentMessages);
if (!assistantInfo) return currentMessages;
if (protectedStreamingAssistantRef.current?.assistantId !== assistantInfo.message.id) protectedStreamingAssistantRef.current = {
assistantId: assistantInfo.message.id,
anchorMessageId: currentMessages[assistantInfo.index - 1]?.id ?? null
};
return moveMessageToEnd(currentMessages, assistantInfo.message.id);
});
}, [setMessages]);
const restoreProtectedStreamingAssistant = useCallback((assistantId) => {
const protection = protectedStreamingAssistantRef.current;
if (!protection || assistantId !== void 0 && protection.assistantId !== assistantId) return;
protectedStreamingAssistantRef.current = null;
setMessages((prevMessages) => {
const sourceIdx = prevMessages.findIndex((m) => m.id === protection.assistantId);
if (sourceIdx < 0) return prevMessages;
const result = [...prevMessages];
const [msg] = result.splice(sourceIdx, 1);
if (!msg) return prevMessages;
if (protection.anchorMessageId === null) result.unshift(msg);
else {
const anchorIdx = result.findIndex((m) => m.id === protection.anchorMessageId);
result.splice(anchorIdx >= 0 ? anchorIdx + 1 : sourceIdx, 0, msg);
}
return result;
});
}, [setMessages]);
const resetMatchingHydratedAssistantForReplay = useCallback((messageId) => {
setMessages((prevMessages) => {
const lastMessage = prevMessages[prevMessages.length - 1];
if (!lastMessage || lastMessage.role !== "assistant" || lastMessage.id !== messageId) return prevMessages;
replayHydratedAssistantMessageIdsRef.current.add(messageId);
const next = [...prevMessages];
next[next.length - 1] = {
...lastMessage,
parts: []
};
return next;
});
}, [setMessages]);
const collapseHydratedReplayTextParts = useCallback((message) => {
const parts = message.parts;
const nextParts = parts.filter((part, index) => {
if (part.type !== "text" || !("text" in part) || !part.text) return true;
return !parts.some((candidate, candidateIndex) => {
if (candidateIndex <= index) return false;
if (candidate.type !== "text" || !("text" in candidate) || !candidate.text) return false;
return candidate.text.startsWith(part.text);
});
});
return nextParts.length === parts.length ? message : {
...message,
parts: nextParts
};
}, []);
useEffect(() => {
if (replayHydratedAssistantMessageIdsRef.current.size === 0) return;
const idsToCollapse = new Set(chatMessages.filter((message) => replayHydratedAssistantMessageIdsRef.current.has(message.id) && message.role === "assistant" && collapseHydratedReplayTextParts(message) !== message).map((message) => message.id));
if (idsToCollapse.size === 0) return;
setMessages((prevMessages) => {
let changed = false;
const nextMessages = prevMessages.map((message) => {
if (!idsToCollapse.has(message.id)) return message;
const nextMessage = collapseHydratedReplayTextParts(message);
if (nextMessage !== message) changed = true;
return nextMessage;
});
return changed ? nextMessages : prevMessages;
});
}, [
chatMessages,
collapseHydratedReplayTextParts,
setMessages
]);
const resetLocalChatState = useCallback(() => {
markInitialMessagesSeeded();
setMessages([]);
setClientToolResults(/* @__PURE__ */ new Map());
setPendingOnToolCallIds(/* @__PURE__ */ new Set());
resetToolContinuation();
processedToolCalls.current.clear();
localResponseMessageIdsRef.current.clear();
pendingReplayResumeRequestIdsRef.current.clear();
fallbackAckedResumeRequestIdsRef.current.clear();
replayHydratedAssistantMessageIdsRef.current.clear();
protectedStreamingAssistantRef.current = null;
}, [
markInitialMessagesSeeded,
setMessages,
resetToolContinuation
]);
const sendMessageWithStreamingProtection = useCallback(async (message, options) => {
const request = sendMessage(message, options);
if (message !== void 0 && !(typeof message === "object" && message !== null && "messageId" in message && message.messageId != null)) protectStreamingAssistantTail();
return request;
}, [sendMessage, protectStreamingAssistantTail]);
const lastMessage = chatMessages[chatMessages.length - 1];
const pendingConfirmations = (() => {
if (!lastMessage || lastMessage.role !== "assistant") return {
messageId: void 0,
toolCallIds: /* @__PURE__ */ new Set()
};
const pendingIds = /* @__PURE__ */ new Set();
for (const part of lastMessage.parts ?? []) if (isToolUIPart(part) && part.state === "input-available" && toolsRequiringConfirmation.includes(getToolName(part))) pendingIds.add(part.toolCallId);
return {
messageId: lastMessage.id,
toolCallIds: pendingIds
};
})();
const pendingConfirmationsRef = useRef(pendingConfirmations);
pendingConfirmationsRef.current = pendingConfirmations;
const [pendingOnToolCallIds, setPendingOnToolCallIds] = useState(() => /* @__PURE__ */ new Set());
const finishOnToolCall = useCallback((toolCallId) => {
setPendingOnToolCallIds((prev) => {
if (!prev.has(toolCallId)) return prev;
const next = new Set(prev);
next.delete(toolCallId);
return next;
});
}, []);
useEffect(() => {
if (!experimental_automaticToolResolution) return;
if (isResolvingToolsRef.current) return;
const lastMsg = chatMessages[chatMessages.length - 1];
if (!lastMsg || lastMsg.role !== "assistant") return;
const toolCalls = lastMsg.parts.filter((part) => isToolUIPart(part) && part.state === "input-available" && !processedToolCalls.current.has(part.toolCallId));
if (toolCalls.length > 0) {
const currentTools = toolsRef.current;
const toolCallsToResolve = toolCalls.filter((part) => isToolUIPart(part) && !toolsRequiringConfirmation.includes(getToolName(part)) && currentTools?.[getToolName(part)]?.execute);
if (toolCallsToResolve.length > 0) {
isResolvingToolsRef.current = true;
(async () => {
try {
const toolResults = [];
for (const part of toolCallsToResolve) if (isToolUIPart(part)) {
let toolOutput = null;
const toolName = getToolName(part);
const tool = currentTools?.[toolName];
if (tool?.execute && part.input !== void 0) try {
toolOutput = await tool.execute(part.input);
} catch (error) {
toolOutput = `Error executing tool: ${error instanceof Error ? error.message : String(error)}`;
}
processedToolCalls.current.add(part.toolCallId);
toolResults.push({
toolCallId: part.toolCallId,
toolName,
output: toolOutput
});
}
if (toolResults.length > 0) {
const clientToolSchemas = extractClientToolSchemas(currentTools);
for (const result of toolResults) agentRef.current.send(JSON.stringify({
type: "cf_agent_tool_result",
toolCallId: result.toolCallId,
toolName: result.toolName,
output: result.output,
autoContinue: autoContinueAfterToolResult,
clientTools: clientToolSchemas
}));
await Promise.all(toolResults.map((result) => addToolResult({
tool: result.toolName,
toolCallId: result.toolCallId,
output: result.output
})));
setClientToolResults((prev) => {
const newMap = new Map(prev);
for (const result of toolResults) newMap.set(result.toolCallId, result.output);
return newMap;
});
startToolContinuation();
}
} finally {
isResolvingToolsRef.current = false;
setToolResolutionTrigger((c) => c + 1);
}
})();
}
}
}, [
chatMessages,
experimental_automaticToolResolution,
addToolResult,
toolsRequiringConfirmation,
autoContinueAfterToolResult,
startToolContinuation,
toolResolutionTrigger
]);
const sendToolOutputToServer = useCallback((toolCallId, toolName, output, state, errorText) => {
const shouldAutoContinue = state === "output-error" ? false : autoContinueAfterToolResult;
agentRef.current.send(JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName,
output,
...state ? { state } : {},
...errorText !== void 0 ? { errorText } : {},
autoContinue: shouldAutoContinue,
clientTools: toolsRef.current ? extractClientToolSchemas(toolsRef.current) : void 0
}));
if (state !== "output-error") setClientToolResults((prev) => new Map(prev).set(toolCallId, output));
if (shouldAutoContinue) startToolContinuation();
}, [autoContinueAfterToolResult, startToolContinuation]);
const sendToolApprovalToServer = useCallback((toolCallId, approved) => {
agentRef.current.send(JSON.stringify({
type: "cf_agent_tool_approval",
toolCallId,
approved,
autoContinue: autoContinueAfterToolResult
}));
if (autoContinueAfterToolResult) startToolContinuation();
}, [autoContinueAfterToolResult, startToolContinuation]);
useEffect(() => {
const currentOnToolCall = onToolCallRef.current;
if (!currentOnToolCall) return;
const lastMsg = chatMessages[chatMessages.length - 1];
if (!lastMsg || lastMsg.role !== "assistant") return;
const pendingToolCalls = lastMsg.parts.filter((part) => isToolUIPart(part) && part.state === "input-available" && !processedToolCalls.current.has(part.toolCallId));
for (const part of pendingToolCalls) if (isToolUIPart(part)) {
const toolCallId = part.toolCallId;
const toolName = getToolName(part);
processedToolCalls.current.add(toolCallId);
setPendingOnToolCallIds((prev) => {
if (prev.has(toolCallId)) return prev;
const next = new Set(prev);
next.add(toolCallId);
return next;
});
const addToolOutput = (opts) => {
sendToolOutputToServer(opts.toolCallId, toolName, opts.output, opts.state, opts.errorText);
addToolResult({
tool: toolName,
toolCallId: opts.toolCallId,
output: opts.state === "output-error" ? opts.errorText ?? "Tool execution denied by user" : opts.output
});
};
let result;
try {
result = currentOnToolCall({
toolCall: {
toolCallId,
toolName,
input: part.input
},
addToolOutput
});
} catch (error) {
finishOnToolCall(toolCallId);
throw error;
}
Promise.resolve(result).finally(() => {
finishOnToolCall(toolCallId);
});
}
}, [
chatMessages,
sendToolOutputToServer,
addToolResult,
finishOnToolCall
]);
const streamStateRef = useRef({ status: "idle" });
const [isServerStreaming, setIsServerStreaming] = useState(false);
const [isRecovering, setIsRecovering] = useState(false);
useEffect(() => {
const localResponseIds = localResponseMessageIdsRef.current;
/**
* Unified message handler that parses JSON once and dispatches based on type.
* Avoids duplicate parsing overhead from separate listeners.
*/
function onAgentMessage(event) {
if (typeof event.data !== "string") return;
let data;
try {
data = JSON.parse(event.data);
} catch (_error) {
return;
}
switch (data.type) {
case "cf_agent_chat_clear":
streamStateRef.current = transition(streamStateRef.current, { type: "clear" }).state;
setIsServerStreaming(false);
setIsRecovering(false);
resetLocalChatState();
break;
case "cf_agent_chat_recovering":
setIsRecovering(Boolean(data.recovering));
break;
case "cf_agent_chat_messages":
setMessages((currentMessages) => {
let next = preserveProtectedStreamingAssistant(data.messages, currentMessages);
const observed = streamStateRef.current;
if (observed.status === "observing" && observed.accumulator.parts.length > 0) {
const snapshotIdx = next.findIndex((m) => m.id === observed.accumulator.messageId);
const snapshotParts = snapshotIdx >= 0 ? next[snapshotIdx].parts.length : 0;
if (observed.accumulator.parts.length >= snapshotParts) next = observed.accumulator.mergeInto(next);
}
return next;
});
break;
case "cf_agent_message_updated":
setMessages((prevMessages) => {
const updatedMessage = data.message;
let idx = prevMessages.findIndex((m) => m.id === updatedMessage.id);
if (idx < 0) {
const updatedToolCallIds = new Set(updatedMessage.parts.filter((p) => "toolCallId" in p && p.toolCallId).map((p) => p.toolCallId));
if (updatedToolCallIds.size > 0) idx = prevMessages.findIndex((m) => m.parts.some((p) => "toolCallId" in p && updatedToolCallIds.has(p.toolCallId)));
}
if (idx >= 0) {
const updated = [...prevMessages];
updated[idx] = {
...updatedMessage,
id: prevMessages[idx].id
};
return updated;
}
return prevMessages;
});
break;
case "cf_agent_stream_resume_none":
if (customTransport.handleStreamResumeNone(data) && data.reason === STREAM_RESUME_NONE_REASONS.IDLE && typeof data.probeId === "string") {
const result = transition(streamStateRef.current, { type: "clear" });
streamStateRef.current = result.state;
setIsServerStreaming(result.isStreaming);
if (observedToolContinuationRequestIdRef.current !== null) resetToolContinuation();
}
break;
case "cf_agent_stream_pending":
customTransport.handleStreamPending();
break;
case "cf_agent_stream_resuming": {
const isEarlyToolContinuation = resumingToolContinuationRef.current && !customTransport.isAwaitingResume();
if (!resume && !customTransport.isAwaitingResume()) {
if (!isEarlyToolContinuation) return;
}
if (!resumingToolContinuationRef.current) pendingReplayResumeRequestIdsRef.current.add(data.id);
if (customTransport.handleStreamResuming(data)) return;
if (localRequestIdsRef.current.has(data.id)) return;
if (fallbackAckedResumeRequestIdsRef.current.has(data.id)) return;
if (isEarlyToolContinuation) {
pendingToolContinuationRef.current = false;
observedToolContinuationRequestIdRef.current = data.id;
if (continuationLaunchTimerRef.current) {
clearTimeout(continuationLaunchTimerRef.current);
continuationLaunchTimerRef.current = null;
}
}
streamStateRef.current = transition(streamStateRef.current, {
type: "resume-fallback",
streamId: data.id,
messageId: nanoid()
}).state;
customTransport.observeServerTurn(data.id);
setIsServerStreaming(true);
setIsRecovering(false);
fallbackAckedResumeRequestIdsRef.current.add(data.id);
agentRef.current.send(JSON.stringify({
type: "cf_agent_stream_resume_ack",
id: data.id
}));
break;
}
case "cf_agent_use_chat_response": {
if (localRequestIdsRef.current.has(data.id)) {
if (data.body?.trim()) try {
const chunkData = JSON.parse(data.body);
if (chunkData.type === "start" && typeof chunkData.messageId === "string") {
const messageId = chunkData.messageId;
localResponseIds.set(data.id, messageId);
if (!data.continuation) {
if (protectedStreamingAssistantRef.current?.assistantId !== messageId) setMessages((currentMessages) => {
const idx = currentMessages.findIndex((message) => message.id === messageId);
protectedStreamingAssistantRef.current = {
assistantId: messageId,
anchorMessageId: idx >= 0 ? currentMessages[idx - 1]?.id ?? null : currentMessages[currentMessages.length - 1]?.id ?? null
};
return currentMessages;
});
}
if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id) {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
resetMatchingHydratedAssistantForReplay(messageId);
}
}
} catch {}
if (data.done || data.error || data.replayComplete) pendingReplayResumeRequestIdsRef.current.delete(data.id);
if (data.done || data.error) {
if (streamStateRef.current.status === "observing" && streamStateRef.current.streamId === data.id) {
streamStateRef.current = { status: "idle" };
setIsServerStreaming(false);
}
customTransport.handleServerTurnCompleted(data.id);
restoreProtectedStreamingAssistant(localResponseIds.get(data.id));
localResponseIds.delete(data.id);
localRequestIdsRef.current.delete(data.id);
fallbackAckedResumeRequestIdsRef.current.delete(data.id);
if (observedToolContinuationRequestIdRef.current === data.id) resetToolContinuation();
}
return;
}
let chunkData;
if (data.replay && streamStateRef.current.status !== "observing" && !pendingReplayResumeRequestIdsRef.current.has(data.id)) return;
if (data.error) {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
customTransport.handleServerTurnCompleted(data.id);
fallbackAckedResumeRequestIdsRef.current.delete(data.id);
setIsRecovering(false);
if (streamStateRef.current.status === "idle" || streamStateRef.current.streamId === data.id) {
const result = transition(streamStateRef.current, { type: "clear" });
streamStateRef.current = result.state;
setIsServerStreaming(result.isStreaming);
}
if (observedToolContinuationRequestIdRef.current === data.id) resetToolContinuation();
break;
}
if (data.body?.trim()) try {
chunkData = JSON.parse(data.body);
if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id && typeof chunkData.messageId === "string" && chunkData.type === "start") {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
resetMatchingHydratedAssistantForReplay(chunkData.messageId);
}
if (typeof chunkData.type === "string" && chunkData.type.startsWith("data-") && onDataRef.current) onDataRef.current(chunkData);
} catch (parseError) {
console.warn("[useAgentChat] Failed to parse stream chunk:", parseError instanceof Error ? parseError.message : parseError, "body:", data.body?.slice(0, 100));
}
if (data.done || data.replayComplete) pendingReplayResumeRequestIdsRef.current.delete(data.id);
if (data.done) {
customTransport.handleServerTurnCompleted(data.id);
fallbackAckedResumeRequestIdsRef.current.delete(data.id);
setIsRecovering(false);
}
const completedObservedToolContinuation = data.done && observedToolContinuationRequestIdRef.current === data.id;
const result = transition(streamStateRef.current, {
type: "response",
streamId: data.id,
messageId: nanoid(),
chunkData,
done: data.done,
error: data.error,
replay: data.replay,
replayComplete: data.replayComplete,
continuation: data.continuation
});
streamStateRef.current = result.state;
if (result.messagesUpdate) setMessages(result.messagesUpdate);
setIsServerStreaming(result.isStreaming);
if (completedObservedToolContinuation) resetToolContinuation();
break;
}
}
}
const fallbackAckedResumeRequestIds = fallbackAckedResumeRequestIdsRef.current;
let socketIsOpen = false;
let sawClose = false;
let disposed = false;
const clearFallbackObserver = () => {
const result = transition(streamStateRef.current, { type: "clear" });
streamStateRef.current = result.state;
setIsServerStreaming(result.isStreaming);
if (observedToolContinuationRequestIdRef.current !== null) resetToolContinuation();
};
const tryPendingReconnectProbe = () => {
if (disposed || !socketIsOpen || !reconnectProbePendingRef.current) return;
if (!resume) {
reconnectProbePendingRef.current = false;
return;
}
if (customTransport.retryPendingResume()) {
reconnectProbePendingRef.current = false;
return;
}
const canReconcileObservedContinuation = observedToolContinuationRequestIdRef.current !== null;
if (statusRef.current !== "ready" && statusRef.current !== "error" || resumingToolContinuationRef.current && !canReconcileObservedContinuation || resumeOperationRef.current !== null) return;
reconnectProbePendingRef.current = false;
resumeStreamRef.current().catch(() => {});
};
reconnectProbeRunnerRef.current = tryPendingReconnectProbe;
function onAgentClose() {
socketIsOpen = false;
sawClose = true;
fallbackAckedResumeRequestIds.clear();
if (!resume) clearFallbackObserver();
}
function onAgentOpen() {
socketIsOpen = true;
if (!sawClose) return;
sawClose = false;
reconnectProbePendingRef.current = true;
tryPendingReconnectProbe();
}
agent.addEventListener("message", onAgentMessage);
agent.addEventListener("close", onAgentClose);
agent.addEventListener("open", onAgentOpen);
return () => {
disposed = true;
if (reconnectProbeRunnerRef.current === tryPendingReconnectProbe) reconnectProbeRunnerRef.current = null;
reconnectProbePendingRef.current = false;
agent.removeEventListener("message", onAgentMessage);
agent.removeEventListener("close", onAgentClose);
agent.removeEventListener("open", onAgentOpen);
fallbackAckedResumeRequestIds.clear();
streamStateRef.current = { status: "idle" };
setIsServerStreaming(false);
setIsRecovering(false);
protectedStreamingAssistantRef.current = null;
localResponseIds.clear();
customTransport.resetResumeState();
invalidateResumeGeneration();
};
}, [
agent,
setMessages,
resume,
customTransport,
preserveProtectedStreamingAssistant,
resetToolContinuation,
resetMatchingHydratedAssistantForReplay,
restoreProtectedStreamingAssistant,
resetLocalChatState,
invalidateResumeGeneration
]);
useEffect(() => {
if (!resume) return;
resumeStream().catch(() => {});
}, [resume, resumeStream]);
useEffect(() => {
reconnectProbeRunnerRef.current?.();
}, [isToolContinuation, status]);
const addToolResultAndSendMessage = async (args) => {
const { toolCallId } = args;
const toolName = "tool" in args ? args.tool : "";
const output = "output" in args ? args.output : void 0;
agentRef.current.send(JSON.stringify({
type: "cf_agent_tool_result",
toolCallId,
toolName,
output,
autoContinue: autoContinueAfterToolResult,
clientTools: toolsRef.current ? extractClientToolSchemas(toolsRef.current) : void 0
}));
setClientToolResults((prev) => new Map(prev).set(toolCallId, output));
addToolResult(args);
if (autoContinueAfterToolResult) startToolContinuation();
if (!autoContinueAfterToolResult) {
if (!autoSendAfterAllConfirmationsResolved) {
sendMessage();
return;
}
const pending = pendingConfirmationsRef.current?.toolCallIds;
if (!pending) {
sendMessage();
return;
}
const wasLast = pending.size === 1 && pending.has(toolCallId);
if (pending.has(toolCallId)) pending.delete(toolCallId);
if (wasLast || pending.size === 0) sendMessage();
}
};
const addToolApprovalResponseAndNotifyServer = (args) => {
const { id: approvalId, approved } = args;
let toolCallId;
setMessages((currentMessages) => {
for (const msg of currentMessages) for (const part of msg.parts) if ("toolCallId" in part && "approval" in part && part.approval?.id === approvalId) {
toolCallId = part.toolCallId;
return currentMessages;
}
return currentMessages;
});
if (toolCallId) sendToolApprovalToServer(toolCallId, approved);
else console.warn(`[useAgentChat] addToolApprovalResponse: Could not find toolCallId for approval ID "${approvalId}". Server will not be notified, which may cause duplicate messages.`);
addToolApprovalResponse(args);
};
const messagesWithToolResults = useMemo(() => {
if (clientToolResults.size === 0) return chatMessages;
return chatMessages.map((msg) => ({
...msg,
parts: msg.parts.map((p) => {
if (!("toolCallId" in p) || !("state" in p) || p.state !== "input-available" || !clientToolResults.has(p.toolCallId)) return p;
return {
...p,
state: "output-available",
output: clientToolResults.get(p.toolCallId)
};
})
}));
}, [chatMessages, clientToolResults]);
useEffect(() => {
const currentToolCallIds = /* @__PURE__ */ new Set();
for (const msg of chatMessages) for (const part of msg.parts) if ("toolCallId" in part && part.toolCallId) currentToolCallIds.add(part.toolCallId);
setClientToolResults((prev) => {
if (prev.size === 0) return prev;
let hasStaleEntries = false;
for (const toolCallId of prev.keys()) if (!currentToolCallIds.has(toolCallId)) {
hasStaleEntries = true;
break;
}
if (!hasStaleEntries) return prev;
const newMap = /* @__PURE__ */ new Map();
for (const [id, output] of prev) if (currentToolCallIds.has(id)) newMap.set(id, output);
return newMap;
});
for (const toolCallId of processedToolCalls.current) if (!currentToolCallIds.has(toolCallId)) processedToolCalls.current.delete(toolCallId);
}, [chatMessages]);
const addToolOutput = useCallback((opts) => {
const toolName = opts.toolName ?? "";
sendToolOutputToServer(opts.toolCallId, toolName, opts.output, opts.state, opts.errorText);
addToolResult({
tool: toolName,
toolCallId: opts.toolCallId,
output: opts.state === "output-error" ? opts.errorText ?? "Tool execution denied by user" : opts.output
});
}, [sendToolOutputToServer, addToolResult]);
const lastAssistantMessage = messagesWithToolResults[messagesWithToolResults.length - 1];
const hasPendingClientToolCalls = (() => {
if (pendingOnToolCallIds.size === 0 && !tools) return false;
if (!lastAssistantMessage || lastAssistantMessage.role !== "assistant") return false;
for (const part of lastAssistantMessage.parts) {
if (!isToolUIPart(part)) continue;
if (part.state !== "input-available") continue;
const toolName = getToolName(part);
if (toolsRequiringConfirmation.includes(toolName)) continue;
if (pendingOnToolCallIds.has(part.toolCallId)) return true;
if (tools?.[toolName]?.execute) return true;
}
return false;
})();
const effectiveIsServerStreaming = isServerStreaming || hasPendingClientToolCalls;
const isStreaming = status === "streaming" || effectiveIsServerStreaming;
return {
...useChatHelpers,
resumeStream,
messages: messagesWithToolResults,
isServerStreaming: effectiveIsServerStreaming,
isStreaming,
/**
* True while a durable chat turn is being recovered (interrupted by a
* deploy/eviction or a stream-stall watchdog abort and now resuming, #1620).
* Distinct from `isStreaming` — a recovering turn isn't producing tokens
* yet. Render a "recovering…" hint; most UIs treat `isStreaming ||
* isRecovering` as "busy". Cleared automatically on the next stream/terminal.
*/
isRecovering,
isToolContinuation,
connectionError: agent.connectionError ?? null,
sendMessage: sendMessageWithStreamingProtection,
stop: stopWithToolContinuationAbort,
/**
* Provide output for a tool call. Use this for tools that require user interaction
* or client-side execution.
*/
addToolOutput,
/**
* @deprecated Use `addToolOutput` instead.
*/
addToolResult: addToolResultAndSendMessage,
/**
* Respond to a tool approval request. Use this for tools with `needsApproval`.
* This wrapper notifies the server before updating local state, preventing
* duplicate messages when sendMessage() is called afterward.
*/
addToolApprovalResponse: addToolApprovalResponseAndNotifyServer,
clearHistory: () => {
resetLocalChatState();
agent.send(JSON.stringify({ type: "cf_agent_chat_clear" }));
},
setMessages: (messagesOrUpdater) => {
setMessages((currentMessages) => {
const resolvedMessages = typeof messagesOrUpdater === "function" ? messagesOrUpdater(currentMessages) : messagesOrUpdater;
if (resolvedMessages.length === 0) markInitialMessagesSeeded();
if (syncMessagesToServer) agent.send(JSON.stringify({
messages: resolvedMessages,
type: "cf_agent_chat_messages"
}));
return resolvedMessages;
});
}
};
}
//#endregion
export { WebSocketChatTransport, detectToolsRequiringConfirmation, extractClientToolSchemas, getAgentMessages, getToolApproval, getToolCallId, getToolInput, getToolOutput, getToolPartState, useAgentChat };
//# sourceMappingURL=react.js.map