-
Notifications
You must be signed in to change notification settings - Fork 696
Expand file tree
/
Copy pathreact.tsx
More file actions
2177 lines (1981 loc) · 76.4 KB
/
Copy pathreact.tsx
File metadata and controls
2177 lines (1981 loc) · 76.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { useChat, type UseChatOptions } from "@ai-sdk/react";
import { getToolName, isToolUIPart } from "ai";
import type {
ChatInit,
JSONSchema7,
Tool,
UIMessage as Message,
UIMessage
} from "ai";
import { nanoid } from "nanoid";
import { use, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { OutgoingMessage } from "./types";
import { MessageType } from "./types";
import { broadcastTransition, type BroadcastStreamState } from "agents/chat";
import {
WebSocketChatTransport,
type AgentConnection
} from "./ws-chat-transport";
/**
* One-shot deprecation warnings (warns once per key per session).
*/
const _deprecationWarnings = new Set<string>();
function warnDeprecated(id: string, message: string) {
if (!_deprecationWarnings.has(id)) {
_deprecationWarnings.add(id);
console.warn(`[@cloudflare/ai-chat] Deprecated: ${message}`);
}
}
// ── DEPRECATED TYPES AND FUNCTIONS ──────────────────────────────────
// Everything in this section is deprecated and will be removed in the
// next major version. Use server-side tools with tool() from "ai" and
// the onToolCall callback in useAgentChat instead.
/**
* JSON Schema type for tool parameters.
* Re-exported from the AI SDK for convenience.
* @deprecated Import JSONSchema7 directly from "ai" instead. Will be removed in the next major version.
*/
export type JSONSchemaType = JSONSchema7;
/**
* Definition for a tool that can be executed on the client.
* Tools with an `execute` function are automatically registered with the server.
*
* **For most apps**, define tools on the server with `tool()` from `"ai"` —
* you get full Zod type safety and simpler code. Use `onToolCall` in
* `useAgentChat` for tools that need browser-side execution.
*
* **For SDKs and platforms** where the tool surface is determined dynamically
* by the embedding application at runtime, this type lets the client register
* tools the server does not know about at deploy time.
*
* Note: Uses `parameters` (JSONSchema7) because client tools must be
* serializable for the wire format. Zod schemas cannot be serialized.
*/
export type AITool<Input = unknown, Output = unknown> = {
/** Human-readable description of what the tool does */
description?: Tool["description"];
/** JSON Schema defining the tool's input parameters */
parameters?: JSONSchema7;
/**
* @deprecated Use `parameters` instead. Will be removed in a future version.
*/
inputSchema?: JSONSchema7;
/**
* Function to execute the tool on the client.
* If provided, the tool schema is automatically sent to the server.
*/
execute?: (input: Input) => Output | Promise<Output>;
};
import type { ClientToolSchema } from "agents/chat";
export type { ClientToolSchema } from "agents/chat";
/**
* 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
*/
export function extractClientToolSchemas(
tools?: Record<string, AITool<unknown, unknown>>
): ClientToolSchema[] | undefined {
if (!tools) return undefined;
const schemas: ClientToolSchema[] = Object.entries(tools)
.filter(([_, tool]) => tool.execute) // Only tools with client-side 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 : undefined;
}
// ── END DEPRECATED TYPES AND FUNCTIONS ─────────────────────────────
// ── Tool part helpers ──────────────────────────────────────────────
//
// `isToolUIPart` and `getToolName` are exported by the AI SDK:
// import { isToolUIPart, getToolName } from "ai";
//
// The helpers below provide additional typed accessors and a
// simplified state mapping that the AI SDK doesn't offer.
/**
* 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") { ... }
* }
* ```
*/
export function getToolPartState(
part: UIMessage["parts"][number]
):
| "loading"
| "streaming"
| "waiting-approval"
| "approved"
| "complete"
| "error"
| "denied" {
const state = (part as { state?: string }).state;
switch (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. */
export function getToolCallId(part: UIMessage["parts"][number]): string {
return (part as { toolCallId: string }).toolCallId;
}
/** Get the tool input from a tool UI part (if available). */
export function getToolInput(
part: UIMessage["parts"][number]
): unknown | undefined {
return (part as { input?: unknown }).input;
}
/** Get the tool output from a tool UI part (if available). */
export function getToolOutput(
part: UIMessage["parts"][number]
): unknown | undefined {
return (part as { output?: unknown }).output;
}
/** Get the approval info from a tool UI part (if in approval state). */
export function getToolApproval(
part: UIMessage["parts"][number]
): { id: string; approved?: boolean } | undefined {
return (part as { approval?: { id: string; approved?: boolean } }).approval;
}
// ── END Tool part helpers ──────────────────────────────────────────
// ── Standalone fetch ───────────────────────────────────────────────
function agentNameToKebab(name: string): string {
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"
* });
* ```
*/
export async function getAgentMessages<M extends UIMessage = UIMessage>(
options:
| {
host: string;
agent: string;
name: string;
credentials?: RequestCredentials;
headers?: HeadersInit;
}
| {
url: string;
credentials?: RequestCredentials;
headers?: HeadersInit;
}
): Promise<M[]> {
let messagesUrl: string;
if ("url" in options) {
messagesUrl = options.url;
} else {
const agentSlug = agentNameToKebab(options.agent);
const base = options.host.endsWith("/")
? options.host.slice(0, -1)
: options.host;
messagesUrl = `${base}/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) as M[];
} catch (error) {
console.warn("[getAgentMessages] Fetch error:", error);
return [];
}
}
// ── END Standalone fetch ───────────────────────────────────────────
type GetInitialMessagesOptions = {
agent: string;
name: string;
url?: string;
};
// v5 useChat parameters
type UseChatParams<M extends UIMessage = UIMessage> = ChatInit<M> &
UseChatOptions<M>;
/**
* Options for preparing the send messages request.
* Used by prepareSendMessagesRequest callback.
*/
export type PrepareSendMessagesRequestOptions<
ChatMessage extends UIMessage = UIMessage
> = {
/** The chat ID */
id: string;
/** Messages to send */
messages: ChatMessage[];
/** What triggered this request */
trigger: "submit-message" | "regenerate-message";
/** ID of the message being sent (if applicable) */
messageId?: string;
/** Request metadata */
requestMetadata?: unknown;
/** Current body (if any) */
body?: Record<string, unknown>;
/** Current credentials (if any) */
credentials?: RequestCredentials;
/** Current headers (if any) */
headers?: HeadersInit;
/** API endpoint */
api?: string;
};
/**
* Return type for prepareSendMessagesRequest callback.
* Allows customizing headers, body, and credentials for each request.
* All fields are optional; only specify what you need to customize.
*/
export type PrepareSendMessagesRequestResult = {
/** Custom headers to send with the request */
headers?: HeadersInit;
/** Custom body data to merge with the request */
body?: Record<string, unknown>;
/** Custom credentials option */
credentials?: RequestCredentials;
/** Custom API endpoint */
api?: string;
};
/**
* Options for addToolOutput function
*/
type AddToolOutputOptions = {
/** The ID of the tool call to provide output for */
toolCallId: string;
/** The name of the tool (optional, for type safety) */
toolName?: string;
/** The output to provide */
output?: unknown;
/** Override the tool part state (e.g. "output-error" for custom denial) */
state?: "output-available" | "output-error";
/** Error message when state is "output-error" */
errorText?: string;
};
/**
* Callback for handling client-side tool execution.
* Called when a tool without server-side execute is invoked.
*/
export type OnToolCallCallback = (options: {
/** The tool call that needs to be handled */
toolCall: {
toolCallId: string;
toolName: string;
input: unknown;
};
/** Function to provide the tool output (or signal an error/denial) */
addToolOutput: (options: Omit<AddToolOutputOptions, "toolName">) => void;
}) => void | Promise<void>;
/**
* Options for the useAgentChat hook
*/
type UseAgentChatOptions<
// oxlint-disable-next-line no-unused-vars -- kept for backward compat
State = unknown,
ChatMessage extends UIMessage = UIMessage
> = Omit<UseChatParams<ChatMessage>, "fetch" | "onToolCall"> & {
/** Agent connection from useAgent (accepts both typed and untyped agents) */
agent: AgentConnection & {
agent: string;
name: string;
path?: ReadonlyArray<{ agent: string; name: string }>;
getHttpUrl: () => string;
};
getInitialMessages?:
| undefined
| null
| ((options: GetInitialMessagesOptions) => Promise<ChatMessage[]>);
/** Request credentials */
credentials?: RequestCredentials;
/** Request headers */
headers?: HeadersInit;
/**
* Callback for handling client-side tool execution.
* Called when a tool without server-side `execute` is invoked by the LLM.
*
* Use this for:
* - Tools that need browser APIs (geolocation, camera, etc.)
* - Tools that need user interaction before providing a result
* - Tools requiring approval before execution
*
* @example
* ```typescript
* onToolCall: async ({ toolCall, addToolOutput }) => {
* if (toolCall.toolName === 'getLocation') {
* const position = await navigator.geolocation.getCurrentPosition();
* addToolOutput({
* toolCallId: toolCall.toolCallId,
* output: { lat: position.coords.latitude, lng: position.coords.longitude }
* });
* }
* }
* ```
*/
onToolCall?: OnToolCallCallback;
/**
* @deprecated Use `onToolCall` callback instead for automatic tool execution.
* @description Whether to automatically resolve tool calls that do not require human interaction.
* @experimental
*/
experimental_automaticToolResolution?: boolean;
/**
* Tools that can be executed on the client. Tool schemas are automatically
* sent to the server and tool calls are routed back for client execution.
*
* **For most apps**, define tools on the server with `tool()` from `"ai"`
* and handle client-side execution via `onToolCall`. This gives you full
* Zod type safety and keeps tool definitions in one place.
*
* **For SDKs and platforms** where tools are defined dynamically by the
* embedding application at runtime, this option lets the client register
* tools the server does not know about at deploy time.
*/
tools?: Record<string, AITool<unknown, unknown>>;
/**
* @deprecated Use `needsApproval` on server-side tools instead.
* @description Manual override for tools requiring confirmation.
* If not provided, will auto-detect from tools object (tools without execute require confirmation).
*/
toolsRequiringConfirmation?: string[];
/**
* When true (default), the server automatically continues the conversation
* after receiving client-side tool results or approvals, similar to how
* server-executed tools work with maxSteps in streamText. The continuation
* is merged into the same assistant message.
*
* When false, the client must call sendMessage() after tool results
* to continue the conversation, which creates a new assistant message.
*
* @default true
*/
autoContinueAfterToolResult?: boolean;
/**
* @deprecated Use `sendAutomaticallyWhen` from AI SDK instead.
*
* When true (default), automatically sends the next message only after
* all pending confirmation-required tool calls have been resolved.
* When false, sends immediately after each tool result.
*
* Only applies when `autoContinueAfterToolResult` is false.
*
* @default true
*/
autoSendAfterAllConfirmationsResolved?: boolean;
/**
* Set to false to disable automatic stream resumption.
* @default true
*/
resume?: boolean;
/**
* Custom data to include in every chat request body.
* Accepts a static object or a function that returns one (for dynamic values).
* These fields are available in `onChatMessage` via `options.body`.
*
* @example
* ```typescript
* // Static
* body: { timezone: "America/New_York", userId: "abc" }
*
* // Dynamic (called on each send)
* body: () => ({ token: getAuthToken(), timestamp: Date.now() })
* ```
*/
body?:
| Record<string, unknown>
| (() => Record<string, unknown> | Promise<Record<string, unknown>>);
/**
* Callback to customize the request before sending messages.
* For most cases, use the `body` option instead.
* Use this for advanced scenarios that need access to the messages or trigger type.
*
* Note: Client tool schemas are automatically sent when tools have `execute` functions.
* This callback can add additional data alongside the auto-extracted schemas.
*/
prepareSendMessagesRequest?: (
options: PrepareSendMessagesRequestOptions<ChatMessage>
) =>
| PrepareSendMessagesRequestResult
| Promise<PrepareSendMessagesRequestResult>;
};
/**
* 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 = new Map<string, Promise<Message[]>>();
function findLastAssistantMessage<ChatMessage extends UIMessage>(
messages: ChatMessage[]
): { index: number; message: ChatMessage } | null {
for (let index = messages.length - 1; index >= 0; index--) {
const message = messages[index];
if (message.role === "assistant") {
return { index, message };
}
}
return null;
}
function moveMessageToEnd<ChatMessage extends UIMessage>(
messages: ChatMessage[],
messageId: string
): ChatMessage[] {
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;
}
/**
* 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.
*/
export function detectToolsRequiringConfirmation(
tools?: Record<string, AITool<unknown, unknown>>
): string[] {
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);
}
export function useAgentChat<
// oxlint-disable-next-line no-unused-vars -- kept for backward compat
State = unknown,
ChatMessage extends UIMessage = UIMessage
>(
options: UseAgentChatOptions<State, ChatMessage>
): Omit<ReturnType<typeof useChat<ChatMessage>>, "addToolOutput"> & {
clearHistory: () => void;
/**
* Provide output for a tool call. Use this for tools that require user interaction
* or client-side execution.
*/
addToolOutput: (opts: AddToolOutputOptions) => void;
/**
* Whether a server-initiated stream (e.g. from `saveMessages`,
* auto-continuation, or another tab) is currently active, OR a
* client-side tool call is awaiting resolution via `onToolCall`.
* Covers the full "turn-in-progress" window from the consumer's
* perspective, including the gap between the model emitting a
* client-tool call and the server pushing a continuation after
* `addToolOutput`. This is independent of the AI SDK's `status`
* which only tracks client-initiated request/response cycles.
*/
isServerStreaming: boolean;
/**
* Convenience flag: `true` when either the client-initiated stream
* (`status === "streaming"`) or a server-initiated stream is active.
* Use this for showing a universal streaming indicator.
*/
isStreaming: boolean;
/**
* `true` when the current `status`/`isServerStreaming` activity is
* driven by a server-pushed tool continuation (i.e. the server is
* auto-continuing the conversation after `addToolOutput` or
* `addToolApprovalResponse`) rather than a fresh user submission.
*
* Use this to disambiguate "user just sent a new message, awaiting
* first token" from "mid-turn tool round-trip" — e.g. when you want
* a typing indicator only for the former:
*
* ```tsx
* const showTypingIndicator = status === "submitted" && !isToolContinuation;
* ```
*
* See issue #1365.
*/
isToolContinuation: boolean;
} {
const {
agent,
getInitialMessages,
messages: optionsInitialMessages,
onToolCall,
onData,
experimental_automaticToolResolution,
tools,
toolsRequiringConfirmation: manualToolsRequiringConfirmation,
autoContinueAfterToolResult = true, // Server auto-continues after tool results/approvals
autoSendAfterAllConfirmationsResolved = true, // Legacy option for client-side batching
resume = true, // Enable stream resumption by default
body: bodyOption,
prepareSendMessagesRequest,
...rest
} = options;
// Emit deprecation warnings for deprecated options (once per session)
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 !== undefined) {
warnDeprecated(
"useAgentChat.autoSendAfterAllConfirmationsResolved",
"The 'autoSendAfterAllConfirmationsResolved' option is deprecated. Use sendAutomaticallyWhen from AI SDK instead. Will be removed in the next major version."
);
}
// ── DEPRECATED: client-side tool confirmation ──────────────────────
// This block will be removed when toolsRequiringConfirmation is removed.
// Only call the deprecated function when deprecated options are actually used.
const toolsRequiringConfirmation = useMemo(() => {
if (manualToolsRequiringConfirmation) {
return manualToolsRequiringConfirmation;
}
// Inline the logic from detectToolsRequiringConfirmation to avoid
// emitting a deprecation warning when tools are provided via the
// non-deprecated `tools` option.
if (!tools) return [];
return Object.entries(tools)
.filter(([_name, tool]) => !tool.execute)
.map(([name]) => name);
}, [manualToolsRequiringConfirmation, tools]);
// Keep refs to always point to the latest callbacks
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 ?? ""]]);
// Cache key for the request-dedup `requestCache` and the late-seed
// effect. It uses the full root-first agent address when `useAgent`
// provides one, so sub-agents with the same leaf class/name under
// different parents do not share hydrated messages.
//
// - Query params like auth tokens change across page loads and
// must not bust the cache, or Suspense re-triggers and breaks
// stream resume (see issue #1223).
// - The origin+pathname portion of the socket URL can legitimately
// transition from empty → resolved on the second render when
// `useAgent()` finishes its handshake. Including it here would
// cause `doGetInitialMessages` to miss the cache after the URL
// arrives, re-invoke the loader, and re-trigger Suspense — the
// exact regression #1356 reports when a custom `getInitialMessages`
// is provided.
//
// `resolvedInitialMessagesCacheKey` is still computed because the
// `stableChatIdRef` logic below uses it to detect the URL-arrival
// transition separately from identity changes.
const resolvedInitialMessagesCacheKey = agentUrl
? `${agentUrl.origin}${agentUrl.pathname}|${agentAddressKey}`
: null;
const initialMessagesCacheKey = agentAddressKey;
// Stable chat ID for `useChat({ id })`.
//
// The AI SDK recreates the underlying Chat instance whenever its `id`
// changes, which aborts any in-flight `transport.reconnectToStream()`
// (the resume path) and leaves the recreated Chat without any resume
// having been fired on it — the AI SDK's `useEffect(() => {
// if (resume) chatRef.current.resumeStream() }, [resume, chatRef])`
// deps are object-stable, so the effect does not re-fire on recreation.
// See issue #1356.
//
// Two things can move across renders and must NOT cause an id flip:
//
// 1. The origin+pathname of the socket URL can transition from
// `null` → resolved on the second render when `useAgent()`
// finishes its handshake. The client-side fallback id gets
// upgraded to the URL-resolved key at that point (one-time).
//
// 2. `agent.name` can transition from the client-side fallback
// ("default") to a server-assigned value when
// `static options = { sendIdentityOnConnect: true }` is set and
// the consumer uses the `basePath` pattern (the server owns the
// DO instance name, not the browser). `useAgent` mutates the
// same agent object's `.name` in place here.
//
// What IS a genuine chat switch: the consumer passes a different
// `agent` object to `useAgentChat`. That's a new `useAgent({...})`
// return value, typically from swapping or remounting a parent. We
// detect this by reference equality — `useAgent`'s return is stable
// across renders for a given mount, so a reference change is the
// unambiguous "chat switch" signal.
const stableChatIdRef = useRef<string | null>(null);
const previousAgentRef = useRef<typeof agent | null>(null);
const previousAgentAddressKeyRef = useRef<string | null>(null);
const fallbackChatId = agentAddressKey;
const agentPathChanged =
Array.isArray(agent.path) &&
previousAgentAddressKeyRef.current !== null &&
previousAgentAddressKeyRef.current !== agentAddressKey;
if (stableChatIdRef.current === null) {
// First render: initialize.
stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId;
} else if (previousAgentRef.current !== agent || agentPathChanged) {
// Consumer swapped in a different agent object, or the full
// sub-agent address changed on a `useAgent` object — genuine chat switch.
// Recompute from current values.
stableChatIdRef.current = resolvedInitialMessagesCacheKey ?? fallbackChatId;
} else if (
resolvedInitialMessagesCacheKey &&
stableChatIdRef.current === fallbackChatId
) {
// URL-arrival upgrade on the same agent: we started on the
// identity-only fallback because the socket URL wasn't known yet.
// Replace with the resolved key now that the handshake has produced
// a real URL — but only on this one-shot transition, never on a
// subsequent `agent.name` mutation.
stableChatIdRef.current = resolvedInitialMessagesCacheKey;
}
previousAgentRef.current = agent;
previousAgentAddressKeyRef.current = agentAddressKey;
// Keep a ref to always point to the latest agent instance.
// Updated synchronously during render (not in useEffect) so the
// transport's agent ref is always current. The transport is a
// singleton whose .agent is reassigned every render — if we used
// useEffect the assignment would lag behind, causing the transport
// to send through a stale/closed socket (issue #929).
const agentRef = useRef(agent);
agentRef.current = agent;
async function defaultGetInitialMessagesFetch({
url
}: GetInitialMessagesOptions) {
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) as ChatMessage[];
} catch (error) {
console.warn("Failed to parse initial messages JSON:", error);
return [];
}
}
const getInitialMessagesFetch =
getInitialMessages || defaultGetInitialMessagesFetch;
function doGetInitialMessages(
getInitialMessagesOptions: GetInitialMessagesOptions,
cacheKey: string
) {
if (requestCache.has(cacheKey)) {
return requestCache.get(cacheKey)! as Promise<ChatMessage[]>;
}
const promise = getInitialMessagesFetch(getInitialMessagesOptions);
requestCache.set(cacheKey, promise);
return promise;
}
const shouldFetchInitialMessages =
getInitialMessages === null
? false
: getInitialMessages
? true
: !!agentUrlString;
const initialMessagesPromise = !shouldFetchInitialMessages
? null
: doGetInitialMessages(
{
agent: agent.agent,
name: agent.name,
url: agentUrlString ?? undefined
},
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]);
// Use synchronous ref updates to avoid race conditions between effect runs.
// This ensures the ref always has the latest value before any effect reads it.
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<Set<string>>(new Set());
const pendingReplayResumeRequestIdsRef = useRef<Set<string>>(new Set());
const replayHydratedAssistantMessageIdsRef = useRef<Set<string>>(new Set());
// WebSocket-based transport that speaks the CF_AGENT protocol natively.
// Replaces the old aiFetch + DefaultChatTransport indirection.
//
// The transport is a true singleton (created once, never recreated) so
// that the resolver set by reconnectToStream and the handleStreamResuming
// call from onAgentMessage always operate on the SAME instance — even
// when _pk changes (async queries, socket recreation) or React Strict
// Mode double-mounts. The agent reference is updated every render so
// sends always go through the latest socket.
const customTransportRef = useRef<WebSocketChatTransport<ChatMessage> | null>(
null
);
if (customTransportRef.current === null) {
customTransportRef.current = new WebSocketChatTransport<ChatMessage>({
agent: agentRef.current,
activeRequestIds: localRequestIdsRef.current,
prepareBody: async ({ messages: msgs, trigger, messageId }) => {
// Start with the top-level body option (static or dynamic)
let extraBody: Record<string, unknown> = {};
const currentBody = bodyOptionRef.current;
if (currentBody) {
const resolved =
typeof currentBody === "function"
? await currentBody()
: currentBody;
extraBody = { ...resolved };
}
// Extract schemas from deprecated client tools (if any)
// Only extract client tool schemas when deprecated tools option is used
if (toolsRef.current) {
const clientToolSchemas = extractClientToolSchemas(toolsRef.current);
if (clientToolSchemas) {
extraBody.clientTools = clientToolSchemas;
}
}
// Apply user's prepareSendMessagesRequest callback (overrides body option)
if (prepareSendMessagesRequestRef.current) {
const userResult = await prepareSendMessagesRequestRef.current({
id: (agentRef.current as unknown as { _pk: string })._pk,
messages: msgs,
trigger,
messageId
});
if (userResult.body) {
Object.assign(extraBody, userResult.body);
}
}
return extraBody;
}
});
}
// Always point the transport at the latest socket so sends/listeners
// go through the current connection after _pk changes.
customTransportRef.current.agent = agentRef.current;
const customTransport = customTransportRef.current;
// Use a stable Chat ID that doesn't change when _pk changes.
// The AI SDK recreates the Chat when `id` changes, which would
// abandon any in-flight makeRequest (including resume) and the
// resume effect wouldn't re-fire (deps are [resume, chatRef]).
// Using the initial messages cache key (URL + agent + name) keeps
// the Chat stable across socket recreations.
const useChatHelpers = useChat<ChatMessage>({
...rest,
onData,
messages: initialMessages,
transport: customTransport,
id: stableChatIdRef.current,
// Pass resume so useChat calls transport.reconnectToStream().
// This lets the AI SDK track status ("streaming") during resume.
resume
});
// Destructure stable method references from useChatHelpers.
// These are individually memoized by the AI SDK (via useCallback), so they're
// safe to use in dependency arrays without causing re-renders. Using them
// directly instead of `useChatHelpers.method` avoids the exhaustive-deps
// warning about the unstable `useChatHelpers` object.
const {
messages: chatMessages,
setMessages,
addToolResult,
addToolApprovalResponse,
sendMessage,
resumeStream,
status,
stop
} = useChatHelpers;
const statusRef = useRef(status);
statusRef.current = status;
const resumingToolContinuationRef = useRef(false);
// Generation counter for tool continuations. Bumped on every
// `startToolContinuation` entry and on any external reset path
// (e.g. `clearHistory`). The `.finally()` handler captures its
// generation at start time and only applies the cleanup if it still
// matches — otherwise the promise is settling after a reset or after
// a newer continuation has already taken over, and its reset would
// clobber current state.
const continuationGenerationRef = useRef(0);
// Mirrors `resumingToolContinuationRef` as React state so consumers can
// distinguish a user-initiated `status === "submitted"` from one driven
// by a server-pushed tool continuation. The ref is kept for its
// synchronous re-entry guard semantics; this state is purely for UI.
// See issue #1365.
const [isToolContinuation, setIsToolContinuation] = useState(false);
// Shared reset for every path that wipes chat history — the local
// `clearHistory()` call AND the server-pushed `CF_AGENT_CHAT_CLEAR`
// handler (another tab or the server itself cleared the chat).
// Without this, a tab with an in-flight tool continuation that
// receives a cross-tab clear would render `isToolContinuation === true`
// over an empty message list until the orphaned `resumeStream()`
// promise eventually settles. Keep ref/state/generation in lockstep;
// the generation bump ensures the pending `.finally()` is a no-op.
const resetToolContinuation = useCallback(() => {
continuationGenerationRef.current++;
resumingToolContinuationRef.current = false;
setIsToolContinuation(false);
}, []);
const startToolContinuation = useCallback(() => {
if (!autoContinueAfterToolResult || resumingToolContinuationRef.current) {