agents
Version:
A home for your AI agents
1,673 lines • 66.3 kB
JavaScript
import { i as transition, r as STREAM_RESUME_NONE_REASONS } from "../wire-types-CU9rLoeS.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";
//#region src/chat/ws-chat-transport.ts
/**
* Short safety-net timeout for a resume probe when the server has said nothing.
* Under normal operation the server answers a `STREAM_RESUME_REQUEST` with
* `STREAM_RESUMING`, `STREAM_RESUME_NONE`, or `STREAM_PENDING` well before this.
*/
const RESUME_PROBE_TIMEOUT_MS = 5e3;
/**
* Extended backstop applied once the server says a turn is pending
* (`STREAM_PENDING`, #1784). The pre-stream window (queueing, MCP setup,
* debounce, model latency) can exceed the short probe timeout, and the server
* guarantees a follow-up `STREAM_RESUMING` or `STREAM_RESUME_NONE` — so we wait
* much longer (refreshed on every keep-waiting frame) but still cap it so a
* dropped follow-up degrades to a null resolve instead of hanging forever.
*/
const RESUME_PENDING_TIMEOUT_MS = 6e4;
/**
* ChatTransport that sends messages over WebSocket and returns a
* ReadableStream<UIMessageChunk> that the AI SDK's useChat consumes directly.
* No fake fetch, no Response reconstruction, no double SSE parsing.
*/
var WebSocketChatTransport = class {
constructor(options) {
this._resumeResolver = null;
this._resumeNoneResolver = null;
this._onStreamPending = null;
this._retryResumeProbe = null;
this._expectToolContinuation = false;
this._abortToolContinuation = null;
this._activeServerTurnId = null;
this._cancelAttachedStream = null;
this._detachResumeStream = null;
this.agent = options.agent;
this.prepareBody = options.prepareBody;
this.activeRequestIds = options.activeRequestIds;
this.cancelOnClientAbort = options.cancelOnClientAbort ?? false;
}
/**
* Point the singleton transport at a new Agent connection. A pending resolver
* belongs to the old Chat/socket generation and must settle before messages
* from the replacement connection can be consumed (#1914 review).
*/
setAgent(agent) {
if (this.agent === agent) return;
this.resetResumeState();
this.agent = agent;
}
setCancelOnClientAbort(cancelOnClientAbort) {
this.cancelOnClientAbort = cancelOnClientAbort;
}
/**
* Explicitly cancel the active server turn, if any.
* This is separate from generic client-side abort/cancel lifecycle so
* clients can detach locally without stopping server work.
*/
cancelActiveServerTurn() {
const requestId = this._activeServerTurnId;
let cancelledRequest = false;
if (requestId) {
this.sendCancelFrame(requestId);
this._cancelAttachedStream?.();
this.clearActiveServerTurn(requestId);
cancelledRequest = true;
}
const cancelledToolContinuation = this.abortActiveToolContinuation();
return cancelledRequest || cancelledToolContinuation;
}
sendCancelFrame(requestId) {
try {
this.agent.send(JSON.stringify({
id: requestId,
type: "cf_agent_chat_request_cancel"
}));
} catch {}
}
setActiveServerTurn(requestId, cancelAttachedStream) {
this._activeServerTurnId = requestId;
this._cancelAttachedStream = cancelAttachedStream;
}
clearActiveServerTurn(requestId) {
if (this._activeServerTurnId === requestId) {
this._activeServerTurnId = null;
this._cancelAttachedStream = null;
}
}
/**
* Mark that the next reconnectToStream() call should attach to a
* server-initiated tool continuation rather than a page-load resume.
*/
expectToolContinuation() {
this._expectToolContinuation = true;
}
/**
* Abort the active client-side tool continuation stream, if one is attached
* to a server request id.
*/
abortActiveToolContinuation() {
return this._abortToolContinuation?.() ?? false;
}
/**
* True when the transport is waiting for a resume handshake.
*/
isAwaitingResume() {
return this._resumeResolver !== null || this._resumeNoneResolver !== null;
}
/**
* Settle and detach the current handshake without interpreting it as a
* server-idle response. Used when the owning hook/agent generation changes.
*/
cancelPendingResume() {
const resolveNone = this._resumeNoneResolver;
if (!resolveNone) return false;
return resolveNone({});
}
/**
* Invalidate all client-side resume state for an obsolete hook/agent
* generation without cancelling its durable server turn.
*/
resetResumeState() {
this._expectToolContinuation = false;
this.cancelPendingResume();
this._detachResumeStream?.();
}
/**
* Re-send the active handshake request on the latest socket generation. This
* preserves one AI SDK resume operation while recovering a request/reply lost
* with the previous WebSocket.
*/
retryPendingResume() {
const retry = this._retryResumeProbe;
if (!retry) return false;
retry();
return true;
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_RESUMING.
* If reconnectToStream is waiting, this handles the resume handshake
* (ACK + stream creation) and returns true. Otherwise returns false
* so the caller can use its own fallback path.
*/
handleStreamResuming(data) {
if (!this._resumeResolver) return false;
this._resumeResolver(data);
return true;
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_RESUME_NONE.
* If reconnectToStream is waiting, resolves the promise with null
* immediately (no 5-second timeout). Returns true if handled.
*/
handleStreamResumeNone(data = {}) {
if (!this._resumeNoneResolver) return false;
return this._resumeNoneResolver(data);
}
/**
* Called by onAgentMessage when it receives CF_AGENT_STREAM_PENDING (#1784):
* the server accepted a turn but its stream has not started yet. If a resume
* path is awaiting, extend its probe timeout (so it keeps waiting for the
* eventual STREAM_RESUMING / STREAM_RESUME_NONE instead of resolving null
* after the short window). Returns true if a waiting path consumed it.
*/
handleStreamPending() {
if (!this._onStreamPending) return false;
this._onStreamPending();
return true;
}
/**
* Called by the hook's shared message handler when a server turn finishes
* outside the currently attached transport stream, such as after local-only
* client cleanup.
*/
handleServerTurnCompleted(requestId) {
this.clearActiveServerTurn(requestId);
}
/**
* Register a server turn that is being rendered outside a transport-owned
* stream, such as the hook's fallback cross-tab/resume observer path.
*/
observeServerTurn(requestId) {
this.setActiveServerTurn(requestId, null);
}
async sendMessages(options) {
const requestId = nanoid(8);
const abortController = new AbortController();
let completed = false;
let requestSent = false;
let extraBody = {};
if (this.prepareBody) extraBody = await this.prepareBody({
messages: options.messages,
trigger: options.trigger,
messageId: options.messageId
});
if (options.body) extraBody = {
...extraBody,
...options.body
};
const bodyPayload = JSON.stringify({
messages: options.messages,
trigger: options.trigger,
...extraBody
});
this.activeRequestIds?.add(requestId);
const agent = this.agent;
const activeIds = this.activeRequestIds;
const finish = (action, keepId = false, clearServerTurn = true) => {
if (completed) return;
completed = true;
if (clearServerTurn) this.clearActiveServerTurn(requestId);
try {
action();
} catch {}
if (!keepId) activeIds?.delete(requestId);
abortController.abort();
};
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
const cancelActiveRequest = () => {
if (completed) return false;
finish(() => streamController.error(abortError), true);
return true;
};
this.setActiveServerTurn(requestId, cancelActiveRequest);
const onAbort = () => {
if (completed) return;
if (this.cancelOnClientAbort) {
if (requestSent) this.sendCancelFrame(requestId);
finish(() => streamController.error(abortError), requestSent);
} else finish(() => streamController.error(abortError), false, !requestSent);
};
let streamController;
const stream = new ReadableStream({
start(controller) {
streamController = controller;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response") return;
if (data.id !== requestId) return;
if (data.error) {
finish(() => controller.error(new Error(data.body || "Stream error")));
return;
}
if (data.body?.trim()) try {
const chunk = JSON.parse(data.body);
controller.enqueue(chunk);
} catch {}
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => {
finish(() => controller.close(), false, false);
};
agent.addEventListener("message", onMessage, { signal: abortController.signal });
agent.addEventListener("close", onClose, { signal: abortController.signal });
},
cancel() {
onAbort();
}
});
if (options.abortSignal) {
options.abortSignal.addEventListener("abort", onAbort, { once: true });
if (options.abortSignal.aborted) onAbort();
}
if (completed) return stream;
requestSent = true;
agent.send(JSON.stringify({
id: requestId,
init: {
method: "POST",
body: bodyPayload
},
type: "cf_agent_use_chat_request"
}));
return stream;
}
async reconnectToStream(_options) {
if (this.isAwaitingResume()) return null;
if (this._expectToolContinuation) {
this._expectToolContinuation = false;
return this._createToolContinuationStream();
}
const activeIds = this.activeRequestIds;
const probeId = nanoid(8);
return new Promise((resolve) => {
let resolved = false;
let timeout;
let resumeResolver = null;
let resumeNoneResolver = null;
let onStreamPending = null;
let retryResumeProbe = null;
const clearOwnedCallbacks = () => {
if (resumeResolver && this._resumeResolver === resumeResolver) this._resumeResolver = null;
if (resumeNoneResolver && this._resumeNoneResolver === resumeNoneResolver) this._resumeNoneResolver = null;
if (onStreamPending && this._onStreamPending === onStreamPending) this._onStreamPending = null;
if (retryResumeProbe && this._retryResumeProbe === retryResumeProbe) this._retryResumeProbe = null;
};
const done = (value) => {
if (resolved) return;
resolved = true;
clearOwnedCallbacks();
if (timeout) clearTimeout(timeout);
resolve(value);
};
const armTimeout = (delay) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => done(null), delay);
};
onStreamPending = () => {
if (resolved) return;
armTimeout(RESUME_PENDING_TIMEOUT_MS);
};
resumeNoneResolver = (data) => {
if (data.probeId && data.probeId !== probeId) return false;
done(null);
return true;
};
resumeResolver = (data) => {
const requestId = data.id;
activeIds?.add(requestId);
const stream = this._createResumeStream(requestId);
this.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_ack",
id: requestId
}));
done(stream);
};
retryResumeProbe = () => {
if (resolved) return;
armTimeout(RESUME_PROBE_TIMEOUT_MS);
try {
this.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_request",
probeId
}));
} catch {}
};
this._onStreamPending = onStreamPending;
this._resumeNoneResolver = resumeNoneResolver;
this._resumeResolver = resumeResolver;
this._retryResumeProbe = retryResumeProbe;
retryResumeProbe();
});
}
/**
* Creates a deferred ReadableStream for client-side tool continuations.
* The stream is returned immediately so AI SDK status becomes "submitted"
* right after addToolOutput()/addToolApprovalResponse(), then it waits for
* the server to announce the continuation via STREAM_RESUMING.
*/
_createToolContinuationStream() {
const agent = this.agent;
const activeIds = this.activeRequestIds;
const streamController = new AbortController();
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
let completed = false;
let requestId = null;
let readerController = null;
const probeId = nanoid(8);
let onResumeRef = null;
let onResumeNoneRef = null;
let onStreamPendingRef = null;
let retryResumeProbeRef = null;
let abortToolContinuationRef = null;
let detachResumeStreamRef = null;
const clearOwnedHandshake = () => {
if (onResumeRef && this._resumeResolver === onResumeRef) this._resumeResolver = null;
if (onResumeNoneRef && this._resumeNoneResolver === onResumeNoneRef) this._resumeNoneResolver = null;
if (onStreamPendingRef && this._onStreamPending === onStreamPendingRef) this._onStreamPending = null;
if (retryResumeProbeRef && this._retryResumeProbe === retryResumeProbeRef) this._retryResumeProbe = null;
};
const finish = (action, keepRequestId = false) => {
if (completed) return;
completed = true;
if (this._abortToolContinuation === abortToolContinuationRef) this._abortToolContinuation = null;
if (this._detachResumeStream === detachResumeStreamRef) this._detachResumeStream = null;
clearOwnedHandshake();
try {
action();
} catch {}
if (requestId && !keepRequestId) activeIds?.delete(requestId);
streamController.abort();
};
const transport = this;
abortToolContinuationRef = () => {
if (completed) return false;
if (requestId === null) {
finish(() => readerController?.error(abortError));
return true;
}
try {
agent.send(JSON.stringify({
type: "cf_agent_chat_request_cancel",
id: requestId
}));
} catch {}
finish(() => readerController?.error(abortError), true);
return true;
};
this._abortToolContinuation = abortToolContinuationRef;
detachResumeStreamRef = () => {
if (completed) return false;
finish(() => readerController?.close());
return true;
};
this._detachResumeStream = detachResumeStreamRef;
return new ReadableStream({
start(controller) {
readerController = controller;
let timeout;
const armTimeout = (delay) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => finish(() => controller.close()), delay);
};
const onResumeNone = (data) => {
if (data.probeId && data.probeId !== probeId) return false;
finish(() => controller.close());
return true;
};
const onResume = (data) => {
if (requestId) return;
requestId = data.id;
activeIds?.add(requestId);
clearOwnedHandshake();
if (timeout) clearTimeout(timeout);
agent.send(JSON.stringify({
type: "cf_agent_stream_resume_ack",
id: requestId
}));
};
const onStreamPending = () => {
if (completed) return;
armTimeout(RESUME_PENDING_TIMEOUT_MS);
};
const retryResumeProbe = () => {
if (completed || requestId !== null) return;
armTimeout(RESUME_PROBE_TIMEOUT_MS);
try {
transport.agent.send(JSON.stringify({
type: "cf_agent_stream_resume_request",
probeId
}));
} catch {}
};
onResumeRef = onResume;
onResumeNoneRef = onResumeNone;
onStreamPendingRef = onStreamPending;
retryResumeProbeRef = retryResumeProbe;
transport._resumeResolver = onResume;
transport._resumeNoneResolver = onResumeNone;
transport._onStreamPending = onStreamPending;
transport._retryResumeProbe = retryResumeProbe;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response" || requestId == null || data.id !== requestId) return;
if (data.error) {
finish(() => controller.error(new Error(data.body || "Stream error")));
return;
}
if (data.body?.trim()) try {
const chunk = JSON.parse(data.body);
controller.enqueue(chunk);
} catch {}
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => finish(() => controller.close());
agent.addEventListener("message", onMessage, { signal: streamController.signal });
agent.addEventListener("close", onClose, { signal: streamController.signal });
retryResumeProbe();
},
cancel() {
if (requestId && transport.cancelOnClientAbort) {
transport.sendCancelFrame(requestId);
finish(() => {}, true);
} else finish(() => {});
}
});
}
/**
* Creates a ReadableStream that receives resumed stream chunks
* and forwards them to useChat as UIMessageChunk objects.
*/
_createResumeStream(requestId) {
const agent = this.agent;
const activeIds = this.activeRequestIds;
const chunkController = new AbortController();
const abortError = /* @__PURE__ */ new Error("Aborted");
abortError.name = "AbortError";
let completed = false;
let detachResumeStream = null;
const finish = (action, keepId = false, clearServerTurn = true) => {
if (completed) return;
completed = true;
if (clearServerTurn) this.clearActiveServerTurn(requestId);
if (this._detachResumeStream === detachResumeStream) this._detachResumeStream = null;
try {
action();
} catch {}
if (!keepId) activeIds?.delete(requestId);
chunkController.abort();
};
let streamController = null;
const cancelActiveRequest = () => {
if (completed) return false;
finish(() => streamController?.error(abortError), true);
return true;
};
this.setActiveServerTurn(requestId, cancelActiveRequest);
const transport = this;
detachResumeStream = () => {
if (completed) return false;
finish(() => streamController?.close());
return true;
};
this._detachResumeStream = detachResumeStream;
return new ReadableStream({
start(controller) {
streamController = controller;
const onMessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "cf_agent_use_chat_response") return;
if (data.id !== requestId) return;
if (data.error) {
finish(() => controller.error(new Error(data.body || "Stream error")));
return;
}
if (data.body?.trim()) try {
const chunk = JSON.parse(data.body);
controller.enqueue(chunk);
} catch {}
if (data.done) finish(() => controller.close());
} catch {}
};
const onClose = () => {
finish(() => controller.close(), false, false);
};
agent.addEventListener("message", onMessage, { signal: chunkController.signal });
agent.addEventListener("close", onClose, { signal: chunkController.signal });
},
cancel() {
if (transport.cancelOnClientAbort) {
transport.sendCancelFrame(requestId);
finish(() => {}, true);
} else finish(() => {}, false, false);
}
});
}
};
//#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, ...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,
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 messagesRef = useRef(chatMessages);
messagesRef.current = chatMessages;
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) => {
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 = messagesRef.current.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;
const assistantInfo = findLastAssistantMessage(messagesRef.current);
if (!assistantInfo) return;
if (protectedStreamingAssistantRef.current?.assistantId !== assistantInfo.message.id) protectedStreamingAssistantRef.current = {
assistantId: assistantInfo.message.id,
anchorMessageId: messagesRef.current[assistantInfo.index - 1]?.id ?? null
};
setMessages((prevMessages) => {
const protection = protectedStreamingAssistantRef.current;
if (!protection) return prevMessages;
return moveMessageToEnd(prevMessages, protection.assistantId);
});
}, [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": {
let next = preserveProtectedStreamingAssistant(data.messages);
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);
}
setMessages(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") {
localResponseIds.set(data.id, chunkData.messageId);
if (!data.continuation) {
if (protectedStreamingAssistantRef.current?.assistantId !== chunkData.messageId) {
const msgs = messagesRef.current;
const idx = msgs.findIndex((m) => m.id === chunkData.messageId);
const anchorMessageId = idx >= 0 ? msgs[idx - 1]?.id ?? null : msgs[msgs.length - 1]?.id ?? null;
protectedStreamingAssistantRef.current = {
assistantId: chunkData.messageId,
anchorMessageId
};
}
}
if (data.replay && !data.continuation && !resumingToolContinuationRef.current && observedToolContinuationRequestIdRef.current !== data.id) {
pendingReplayResumeRequestIdsRef.current.delete(data.id);
resetMatchingHydratedAssistantForReplay(chunkData.messageId);
}
}
} catch {}
if (data.done || data.replayComplete) pendingReplayResumeRequestIdsRef.current.delete(data.id);
if (data.done) {
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.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,
currentMessages: data.continuation ? messagesRef.current : void 0
});
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;
for (const msg of messagesRef.current) {
for (const part of msg.parts) if ("toolCallId" in part && "approval" in part && part.approval?.id === approvalId) {
toolCallId = part.toolCallId;
break;
}
if (toolCallId) break;
}
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) => {
let resolvedMessages;
if (typeof messagesOrUpdater === "function") resolvedMessages = messagesOrUpdater(messagesRef.current);
else resolvedMessages = messagesOrUpdater;
if (resolvedMessages.length === 0) markInitialMessagesSeeded();
setMessages(resolvedMessages);
if (syncMessagesToServer) agent.send(JSON.stringify({
messages: resolvedMessages,
type: "cf_agent_chat_messages"
}));
}
};
}
//#endregion
export { WebSocketChatTransport, detectToolsRequiringConfirmation, extractClientToolSchemas, getAgentMessages, getToolApproval, getToolCallId, getToolInput, getToolOutput, getToolPartState, useAgentChat };
//# sourceMappingURL=react.js.map