@springbrand/chat-client 0.1.3-alpha.8 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@springbrand/chat-client",
3
- "version": "0.1.3-alpha.8",
3
+ "version": "0.3.0-alpha.1",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -13,7 +13,8 @@
13
13
  ".": "./src/index.ts"
14
14
  },
15
15
  "peerDependencies": {
16
- "agents": "^0.20.1",
16
+ "@cloudflare/ai-chat": "^0.11.0",
17
+ "agents": "^0.22.0",
17
18
  "ai": "^7.0.0",
18
19
  "react": "^19.0.0"
19
20
  },
@@ -21,11 +22,12 @@
21
22
  "nanoid": "^5.1.16"
22
23
  },
23
24
  "devDependencies": {
24
- "@types/react": "^19.2.17",
25
- "@types/react-dom": "^19.2.3",
26
- "react-dom": "^19.2.7",
25
+ "@cloudflare/ai-chat": "0.11.0",
26
+ "@types/react": "^19.2.18",
27
+ "@types/react-dom": "^19.2.5",
28
+ "react-dom": "^19.2.8",
27
29
  "typescript": "^7.0.2",
28
- "@springbrand/agent-runtime": "0.1.3-alpha.11"
30
+ "@springbrand/agent-runtime": "0.3.0-alpha.1"
29
31
  },
30
32
  "scripts": {
31
33
  "typecheck": "tsc --noEmit"
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useAgent } from "agents/react";
3
3
 
4
- interface InboxState<Session> {
4
+ export interface InboxState<Session> {
5
5
  chats: Session[];
6
6
  workspaceRev?: number;
7
7
  }
@@ -33,8 +33,11 @@ export async function loadChatSessions<Session>(
33
33
  }
34
34
 
35
35
  /** Browser client for the user Inbox. Routing and presentation stay with the host UI. */
36
- export function useChatSessions<Session>(options: ChatSessionsOptions = {}) {
37
- const inbox = useAgent<InboxState<Session>>({
36
+ export function useChatSessions<
37
+ Session,
38
+ State extends InboxState<Session> = InboxState<Session>,
39
+ >(options: ChatSessionsOptions = {}) {
40
+ const inbox = useAgent<State>({
38
41
  agent: "Inbox",
39
42
  basePath: CURRENT_INBOX_AGENT_PATH,
40
43
  ...(options.protocols === undefined ? {} : { protocols: options.protocols }),
@@ -93,6 +96,7 @@ export function useChatSessions<Session>(options: ChatSessionsOptions = {}) {
93
96
  synced: inbox.state !== undefined || snapshot !== undefined,
94
97
  connected: inbox.identified,
95
98
  workspaceRev: inbox.state?.workspaceRev ?? 0,
99
+ inboxState: inbox.state,
96
100
  createChat,
97
101
  forkChat,
98
102
  renameChat,
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export {
2
+ UniversalAgentChatProvider,
2
3
  useUniversalAgentChat,
3
4
  type AgentToolRun,
5
+ type ChatConnectionStatus,
4
6
  type ChatConnectionOptions,
5
7
  type ChatRuntime,
6
8
  } from "./use-universal-agent-chat";
@@ -21,4 +23,5 @@ export {
21
23
  useChatSessions,
22
24
  type ChatSessionsOptions,
23
25
  type ChatSessions,
26
+ type InboxState,
24
27
  } from "./chat-sessions";
@@ -1,11 +1,24 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1
+ import {
2
+ createContext,
3
+ createElement,
4
+ Suspense,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ReactNode,
12
+ } from "react";
2
13
  import { nanoid } from "nanoid";
3
14
  import type { ChatStatus, FileUIPart, UIMessage } from "ai";
4
15
  import type { AgentToolRunState } from "agents";
16
+ import type { AgentConnectionError } from "agents/client";
5
17
  import { MessageType } from "agents/chat";
6
- import { getAgentMessages, useAgentChat } from "agents/chat/react";
18
+ import { getAgentMessages, useAgentChat } from "@cloudflare/ai-chat/react";
7
19
  import { useAgent, useAgentToolEvents } from "agents/react";
8
20
  import {
21
+ type ApprovalDecision,
9
22
  type MessageDelivery,
10
23
  type MessageDispatchReceipt,
11
24
  type RequestedCapability,
@@ -13,15 +26,32 @@ import {
13
26
  type RuntimeConfigUpdateResult,
14
27
  type RuntimeQueuedSubmission,
15
28
  type RuntimeState,
29
+ type RuntimeTurnState,
16
30
  } from "@springbrand/agent-runtime/contracts";
17
31
  import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
18
32
  import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
19
33
 
20
34
  type ChatMessageMetadata = Record<string, unknown> & {
35
+ completedAt?: number;
36
+ error?: string;
37
+ interruptedByUser?: boolean;
38
+ // AI SDK keeps one assistant UIMessage for the live stream. This anchor is
39
+ // persisted in the user sidecar so live and hydrated presentation agree.
40
+ optimisticSteer?: {
41
+ assistantMessageId: string;
42
+ afterStep: number;
43
+ };
21
44
  requestedCapabilities?: readonly RequestedCapability[];
45
+ turnId?: string;
46
+ userMessageId?: string;
22
47
  turnStatus?: string;
23
48
  };
24
49
  type ChatMessage = UIMessage<ChatMessageMetadata>;
50
+ export type ChatConnectionStatus =
51
+ | "connecting"
52
+ | "connected"
53
+ | "rejected"
54
+ | "error";
25
55
  type FailedDispatch = {
26
56
  message: ChatMessage;
27
57
  delivery: MessageDelivery;
@@ -62,35 +92,20 @@ type ChatApproval = {
62
92
  requestId: string;
63
93
  };
64
94
 
65
- type ChatQueuedSubmission = {
66
- submissionId: string;
67
- messageId: string;
68
- preview: string;
69
- position: number;
70
- createdAt: number;
71
- };
72
-
73
- type ChatTurn = {
74
- activeSubmissionId?: string;
75
- activeRequestId?: string;
76
- recoveryAttempt?: number;
77
- recoveryMax?: number;
78
- recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
79
- steerable: boolean;
80
- hasPendingSteer: boolean;
81
- queued: ChatQueuedSubmission[];
82
- };
83
-
84
95
  export interface ChatRuntime {
85
96
  messages: ChatMessage[];
86
97
  status: ChatStatus;
98
+ connectionStatus: ChatConnectionStatus;
99
+ connectionError: AgentConnectionError | undefined;
87
100
  runtimeLoad: ChatRuntimeLoadState | undefined;
88
101
  isStreaming: boolean;
89
102
  error: string | Error | undefined;
103
+ /** Reusing messageId makes delivery idempotent across caller retries. */
90
104
  sendText: (
91
105
  text: string,
92
106
  files?: readonly FileUIPart[],
93
107
  capabilities?: readonly RequestedCapability[],
108
+ messageId?: string,
94
109
  ) => Promise<boolean>;
95
110
  steerText: (
96
111
  text: string,
@@ -115,6 +130,10 @@ export interface ChatRuntime {
115
130
  toolCallId: string,
116
131
  response: unknown,
117
132
  ) => Promise<boolean>;
133
+ decideApproval: (
134
+ executionId: string,
135
+ decision: ApprovalDecision,
136
+ ) => Promise<{ ok: boolean }>;
118
137
  /**
119
138
  * 读取该 Session facet 当前真正装配出来的 Runtime。
120
139
  *
@@ -124,6 +143,10 @@ export interface ChatRuntime {
124
143
  * 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
125
144
  */
126
145
  getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
146
+ getRuntimeBinding: <Binding = unknown>() => Promise<Binding>;
147
+ readChatMetadata: <Metadata = unknown>() => Promise<Metadata>;
148
+ renameChat: <Metadata = unknown>(title: string) => Promise<Metadata>;
149
+ rebindRuntime: <Input = unknown, Result = unknown>(input: Input) => Promise<Result>;
127
150
  updateConfig: <Change = unknown>(
128
151
  command: unknown,
129
152
  ) => Promise<RuntimeConfigUpdateResult<Change>>;
@@ -136,7 +159,7 @@ export interface ChatRuntime {
136
159
  isToolContinuation: boolean;
137
160
  approvals: ChatApproval[] | undefined;
138
161
  approvalsLoaded: boolean;
139
- turn: ChatTurn | undefined;
162
+ turn: RuntimeTurnState | undefined;
140
163
  turnActive: boolean;
141
164
  canSteer: boolean;
142
165
  }
@@ -145,8 +168,15 @@ type SharedChatConnectionOptions = {
145
168
  host?: string;
146
169
  credentials?: RequestCredentials;
147
170
  protocols?: string | string[];
171
+ onMessage?: (message: MessageEvent) => void;
148
172
  inboxAgent?: string;
149
173
  sessionAgent?: string;
174
+ /** Skip history loading when the caller has just created this Chat. */
175
+ skipInitialMessages?: boolean;
176
+ /** Host projection used only until the selected Session sends its first state. */
177
+ initialTurnActive?: boolean;
178
+ /** Optional Agent RPC method injected by the Host to receive Turn timings. */
179
+ performanceRpc?: string;
150
180
  };
151
181
 
152
182
  export type ChatConnectionOptions = SharedChatConnectionOptions & (
@@ -154,9 +184,61 @@ export type ChatConnectionOptions = SharedChatConnectionOptions & (
154
184
  | { inboxName?: never; basePath?: string }
155
185
  );
156
186
 
187
+ type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
188
+ type UniversalAgentChatContextValue = {
189
+ chatId: string;
190
+ connection: ChatConnectionOptions;
191
+ agent: SessionAgentConnection;
192
+ chatAgent: SessionAgentConnection;
193
+ };
194
+
195
+ const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
196
+
197
+ export function UniversalAgentChatProvider({
198
+ chatId,
199
+ connection = {},
200
+ fallback = null,
201
+ children,
202
+ }: {
203
+ chatId: string;
204
+ connection?: ChatConnectionOptions;
205
+ fallback?: ReactNode;
206
+ children: ReactNode;
207
+ }) {
208
+ const inboxAgent = connection.inboxAgent ?? "Inbox";
209
+ const agent = useAgent<RuntimeState>({
210
+ agent: inboxAgent,
211
+ ...(connection.inboxName === undefined
212
+ ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
213
+ : { name: connection.inboxName }),
214
+ ...(connection.host === undefined ? {} : { host: connection.host }),
215
+ ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
216
+ ...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
217
+ connectionTimeout: 6_000,
218
+ sub: [{
219
+ agent: connection.sessionAgent ?? "UniversalAgent",
220
+ name: chatId,
221
+ }],
222
+ });
223
+ const chatAgent = useMemo(
224
+ () => createSafeUIMessageAgentConnection(agent),
225
+ [agent],
226
+ );
227
+ const value = useMemo(
228
+ () => ({ chatId, connection, agent, chatAgent }),
229
+ [agent, chatAgent, chatId, connection],
230
+ );
231
+ return createElement(
232
+ UniversalAgentChatContext.Provider,
233
+ { value },
234
+ createElement(Suspense, { fallback }, children),
235
+ );
236
+ }
237
+
157
238
  type ChatTurnTiming = {
158
239
  chatId: string;
159
240
  messageId: string;
241
+ submissionId?: string;
160
242
  baselineAssistantIds: Set<string>;
161
243
  click: number;
162
244
  rpcReceipt?: number;
@@ -165,6 +247,37 @@ type ChatTurnTiming = {
165
247
  ready?: number;
166
248
  };
167
249
 
250
+ type ClientPerformanceMetrics = {
251
+ clickToRpcMs: number;
252
+ rpcToStreamMs: number;
253
+ streamToFirstTextMs?: number;
254
+ firstTextToReadyMs?: number;
255
+ ttftMs?: number;
256
+ totalMs: number;
257
+ };
258
+
259
+ export function clientPerformanceMetrics(
260
+ timing: ChatTurnTiming,
261
+ ): ClientPerformanceMetrics | null {
262
+ if (
263
+ timing.rpcReceipt === undefined ||
264
+ timing.streamStart === undefined ||
265
+ timing.ready === undefined
266
+ ) return null;
267
+ return {
268
+ clickToRpcMs: timing.rpcReceipt - timing.click,
269
+ rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
270
+ ...(timing.firstText === undefined
271
+ ? {}
272
+ : {
273
+ streamToFirstTextMs: timing.firstText - timing.streamStart,
274
+ firstTextToReadyMs: timing.ready - timing.firstText,
275
+ ttftMs: timing.firstText - timing.click,
276
+ }),
277
+ totalMs: timing.ready - timing.click,
278
+ };
279
+ }
280
+
168
281
  const CHAT_TURN_SEGMENTS = {
169
282
  rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
170
283
  streamStart: [
@@ -210,6 +323,7 @@ function createChatMessage(
210
323
  text: string,
211
324
  files?: readonly FileUIPart[],
212
325
  capabilities: readonly RequestedCapability[] = [],
326
+ messageId?: string,
213
327
  ): ChatMessage | null {
214
328
  const normalizedText = text.trim();
215
329
  if (!normalizedText && !files?.length) return null;
@@ -222,7 +336,7 @@ function createChatMessage(
222
336
  return true;
223
337
  });
224
338
  return {
225
- id: nanoid(),
339
+ id: messageId ?? nanoid(),
226
340
  role: "user",
227
341
  parts: [
228
342
  ...(files ?? []),
@@ -243,10 +357,204 @@ export function mergeOptimisticMessages<T extends UIMessage>(
243
357
  messages: T[],
244
358
  optimistic: T[],
245
359
  ): T[] {
246
- if (optimistic.length === 0) return messages;
247
360
  const ids = new Set(messages.map(({ id }) => id));
248
361
  const pending = optimistic.filter(({ id }) => !ids.has(id));
249
- return pending.length === 0 ? messages : [...messages, ...pending];
362
+ const assistantsByTurn = new Map<string, T[]>();
363
+ for (const message of messages) {
364
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
365
+ if (message.role !== "assistant" || typeof turnId !== "string") continue;
366
+ const assistants = assistantsByTurn.get(turnId) ?? [];
367
+ assistants.push(message);
368
+ assistantsByTurn.set(turnId, assistants);
369
+ }
370
+ const assistantPosition = new Map<string, {
371
+ assistantMessageId: string;
372
+ stepOffset: number;
373
+ }>();
374
+ for (const assistants of assistantsByTurn.values()) {
375
+ let stepOffset = 0;
376
+ for (const assistant of assistants) {
377
+ assistantPosition.set(assistant.id, {
378
+ assistantMessageId: assistants[0]!.id,
379
+ stepOffset,
380
+ });
381
+ stepOffset += assistant.parts.filter(
382
+ ({ type }) => type === "step-start",
383
+ ).length;
384
+ }
385
+ }
386
+ const normalizeAnchor = (anchor: NonNullable<
387
+ ChatMessageMetadata["optimisticSteer"]
388
+ >) => {
389
+ const position = assistantPosition.get(anchor.assistantMessageId);
390
+ return position
391
+ ? {
392
+ assistantMessageId: position.assistantMessageId,
393
+ afterStep: position.stepOffset + anchor.afterStep,
394
+ }
395
+ : anchor;
396
+ };
397
+ const logicalMessages: T[] = [];
398
+ const logicalAssistantIndex = new Map<string, number>();
399
+ for (const message of messages) {
400
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
401
+ if (message.role !== "assistant" || typeof turnId !== "string") {
402
+ logicalMessages.push(message);
403
+ continue;
404
+ }
405
+ const existingIndex = logicalAssistantIndex.get(turnId);
406
+ if (existingIndex === undefined) {
407
+ logicalAssistantIndex.set(turnId, logicalMessages.length);
408
+ logicalMessages.push(message);
409
+ continue;
410
+ }
411
+ const existing = logicalMessages[existingIndex]!;
412
+ logicalMessages[existingIndex] = {
413
+ ...existing,
414
+ parts: [...existing.parts, ...message.parts],
415
+ metadata: {
416
+ ...(existing.metadata as Record<string, unknown> | undefined),
417
+ ...(message.metadata as Record<string, unknown> | undefined),
418
+ },
419
+ } as T;
420
+ }
421
+ const authoritativeAnchors = messages.flatMap((message, messageIndex) => {
422
+ const anchor = (message.metadata as ChatMessageMetadata | undefined)
423
+ ?.optimisticSteer;
424
+ if (anchor) return [{ message, anchor: normalizeAnchor(anchor) }];
425
+ if (message.role !== "user") return [];
426
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
427
+ const assistants = typeof turnId === "string"
428
+ ? assistantsByTurn.get(turnId)
429
+ : undefined;
430
+ if (!assistants || assistants.length === 0) return [];
431
+ const openingMessageId = (assistants[0]!.metadata as
432
+ | ChatMessageMetadata
433
+ | undefined)?.userMessageId;
434
+ if (message.id === openingMessageId) return [];
435
+ const preceding = messages.slice(0, messageIndex).filter((candidate) =>
436
+ candidate.role === "assistant" &&
437
+ (candidate.metadata as ChatMessageMetadata | undefined)?.turnId === turnId
438
+ );
439
+ return [{
440
+ message,
441
+ anchor: {
442
+ assistantMessageId: assistants[0]!.id,
443
+ afterStep: preceding.reduce((total, assistant) =>
444
+ total + assistant.parts.filter(
445
+ ({ type }) => type === "step-start",
446
+ ).length, 0),
447
+ },
448
+ }];
449
+ });
450
+ const pendingAnchors = pending.flatMap((message) => {
451
+ const anchor = (message.metadata as ChatMessageMetadata | undefined)
452
+ ?.optimisticSteer;
453
+ return anchor ? [{ message, anchor: normalizeAnchor(anchor) }] : [];
454
+ });
455
+ const anchoredSteers = [...authoritativeAnchors, ...pendingAnchors];
456
+ if (pending.length === 0 && anchoredSteers.length === 0) return messages;
457
+ const anchoredIds = new Set(
458
+ anchoredSteers.map(({ message }) => message.id),
459
+ );
460
+ const pendingById = new Map(
461
+ pending
462
+ .filter(({ id }) => !anchoredIds.has(id))
463
+ .map((message) => [message.id, message]),
464
+ );
465
+ const withPrompts = logicalMessages
466
+ .filter(({ id }) => !anchoredIds.has(id))
467
+ .flatMap((message) => {
468
+ const userMessageId = message.role === "assistant"
469
+ ? (message.metadata as ChatMessageMetadata | undefined)?.userMessageId
470
+ : undefined;
471
+ if (typeof userMessageId !== "string") return [message];
472
+ const prompt = pendingById.get(userMessageId);
473
+ if (!prompt) return [message];
474
+ pendingById.delete(userMessageId);
475
+ return [prompt, message];
476
+ });
477
+ const unmatchedPrompts = pending.filter(({ id }) => pendingById.has(id));
478
+ if (anchoredSteers.length === 0) {
479
+ return [
480
+ ...withPrompts,
481
+ ...unmatchedPrompts,
482
+ ];
483
+ }
484
+ const targetAssistantIds = new Set(
485
+ anchoredSteers.map(({ anchor }) => anchor.assistantMessageId),
486
+ );
487
+ const firstTarget = withPrompts.findIndex(({ id, role }) =>
488
+ role === "assistant" && targetAssistantIds.has(id)
489
+ );
490
+ const base = firstTarget < 0
491
+ ? [...withPrompts, ...unmatchedPrompts]
492
+ : [
493
+ ...withPrompts.slice(0, firstTarget),
494
+ ...unmatchedPrompts,
495
+ ...withPrompts.slice(firstTarget),
496
+ ];
497
+ const byAssistant = new Map<string, typeof anchoredSteers>();
498
+ for (const anchored of anchoredSteers) {
499
+ const group = byAssistant.get(anchored.anchor.assistantMessageId) ?? [];
500
+ group.push(anchored);
501
+ byAssistant.set(anchored.anchor.assistantMessageId, group);
502
+ }
503
+ const placed = new Set<string>();
504
+ const merged = base.flatMap((message) => {
505
+ if (message.role !== "assistant") return [message];
506
+ const steers = byAssistant.get(message.id);
507
+ if (!steers) return [message];
508
+
509
+ const projected: T[] = [];
510
+ const stepStarts = message.parts.flatMap((part, index) =>
511
+ part.type === "step-start" ? [index] : []
512
+ );
513
+ let cursor = 0;
514
+ let minimumBoundary = 1;
515
+ for (let index = 0; index < steers.length;) {
516
+ const boundary = Math.max(
517
+ minimumBoundary,
518
+ steers[index]!.anchor.afterStep,
519
+ );
520
+ minimumBoundary = boundary;
521
+ let end = index + 1;
522
+ while (
523
+ end < steers.length &&
524
+ steers[end]!.anchor.afterStep <= boundary
525
+ ) {
526
+ end += 1;
527
+ }
528
+ const segmentEnd = stepStarts[boundary] ?? message.parts.length;
529
+ if (segmentEnd > cursor) {
530
+ projected.push({
531
+ ...message,
532
+ id: `${message.id}:segment:${projected.length}`,
533
+ parts: message.parts.slice(cursor, segmentEnd),
534
+ });
535
+ }
536
+ for (const anchored of steers.slice(index, end)) {
537
+ projected.push(anchored.message);
538
+ placed.add(anchored.message.id);
539
+ }
540
+ cursor = segmentEnd;
541
+ index = end;
542
+ }
543
+ if (cursor < message.parts.length) {
544
+ projected.push({
545
+ ...message,
546
+ id: `${message.id}:segment:${projected.length}`,
547
+ parts: message.parts.slice(cursor),
548
+ });
549
+ }
550
+ return projected;
551
+ });
552
+ return [
553
+ ...merged,
554
+ ...anchoredSteers
555
+ .filter(({ message }) => !placed.has(message.id))
556
+ .map(({ message }) => message),
557
+ ];
250
558
  }
251
559
 
252
560
  function queuedPreview(message: ChatMessage): string {
@@ -260,10 +568,12 @@ function queuedPreview(message: ChatMessage): string {
260
568
  function loadInitialMessages(
261
569
  url: string | undefined,
262
570
  credentials: RequestCredentials | undefined,
571
+ view: "hydration" | "full" = "hydration",
263
572
  ): Promise<ChatMessage[]> {
264
573
  if (!url) return Promise.resolve([]);
265
574
  const messagesUrl = new URL(url);
266
575
  messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
576
+ if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
267
577
  return getAgentMessages({
268
578
  url: messagesUrl.toString(),
269
579
  ...(credentials === undefined ? {} : { credentials }),
@@ -281,10 +591,12 @@ function loadInitialMessages(
281
591
  * sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
282
592
  * 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
283
593
  */
284
- export function useUniversalAgentChat(
285
- chatId: string,
286
- connection: ChatConnectionOptions = {},
287
- ): ChatRuntime {
594
+ export function useUniversalAgentChat(): ChatRuntime {
595
+ const context = useContext(UniversalAgentChatContext);
596
+ if (!context) {
597
+ throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
598
+ }
599
+ const { chatId, connection, agent, chatAgent } = context;
288
600
  const [optimisticMessages, setOptimisticMessages] =
289
601
  useState<ChatMessage[]>([]);
290
602
  const [optimisticQueued, setOptimisticQueued] =
@@ -292,25 +604,7 @@ export function useUniversalAgentChat(
292
604
  const [dispatchError, setDispatchError] = useState<string>();
293
605
  const [canRetry, setCanRetry] = useState(false);
294
606
  const failedDispatchRef = useRef<FailedDispatch | undefined>(undefined);
295
- const activeSubmissionIdRef = useRef<string | undefined>(undefined);
296
- const inboxAgent = connection.inboxAgent ?? "Inbox";
297
- const agent = useAgent<RuntimeState>({
298
- agent: inboxAgent,
299
- ...(connection.inboxName === undefined
300
- ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
301
- : { name: connection.inboxName }),
302
- ...(connection.host === undefined ? {} : { host: connection.host }),
303
- ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
304
- sub: [{
305
- agent: connection.sessionAgent ?? "UniversalAgent",
306
- name: chatId,
307
- }],
308
- });
309
- const chatAgent = useMemo(
310
- () => createSafeUIMessageAgentConnection(agent),
311
- [agent],
312
- );
313
-
607
+ const stopRevisionRef = useRef(0);
314
608
  // 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
315
609
  // AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
316
610
  // 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
@@ -326,27 +620,32 @@ export function useUniversalAgentChat(
326
620
  isStreaming,
327
621
  error,
328
622
  clearError,
623
+ setMessages,
624
+ stop: stopChat,
329
625
  // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
330
626
  // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
331
627
  isServerStreaming,
332
- isRecovering,
628
+ isRecovering: sdkIsRecovering,
333
629
  isToolContinuation,
334
- connectionError,
630
+ connectionError: sdkConnectionError,
335
631
  } = useAgentChat<RuntimeState, ChatMessage>({
336
632
  agent: chatAgent,
337
- getInitialMessages: ({ url }) =>
338
- loadInitialMessages(url, connection.credentials),
633
+ getInitialMessages: connection.skipInitialMessages
634
+ ? null
635
+ : ({ url }) => loadInitialMessages(url, connection.credentials),
339
636
  ...(connection.credentials === undefined
340
637
  ? {}
341
638
  : { credentials: connection.credentials }),
342
639
  syncMessagesToServer: false,
343
- throttle: 100,
344
640
  });
641
+ // The SDK keeps its advisory recovery flag until terminal settlement; a
642
+ // live server stream proves the recovered Turn is executing again.
643
+ const isRecovering = sdkIsRecovering && !isServerStreaming;
345
644
  const visibleMessages = useMemo(
346
645
  () => mergeOptimisticMessages(messages, optimisticMessages),
347
646
  [messages, optimisticMessages],
348
647
  );
349
- const authoritativeTurn = facetState?.turn;
648
+ const reportedTurn = facetState?.turn;
350
649
  const stoppedByUser = error?.message === USER_STOP_REASON;
351
650
  const latestMessage = visibleMessages.at(-1);
352
651
  const latestTurnStatus = latestMessage?.role === "assistant"
@@ -356,15 +655,40 @@ export function useUniversalAgentChat(
356
655
  latestTurnStatus === "error" ||
357
656
  latestTurnStatus === "aborted" ||
358
657
  latestTurnStatus === "skipped";
658
+ // Messages and Agent state arrive in separate frames. A terminal message for
659
+ // the same Submission is newer than a still-active state projection; a
660
+ // different active ID belongs to the next Turn and must remain live.
661
+ const authoritativeTurn = reportedTurn &&
662
+ hasTerminalMessage &&
663
+ latestMessage?.metadata?.turnId === reportedTurn.activeSubmissionId
664
+ ? {
665
+ steerable: false,
666
+ hasPendingSteer: false,
667
+ queued: reportedTurn.queued,
668
+ }
669
+ : reportedTurn;
359
670
  const normalizedSdkStatus = sdkStatus === "error" &&
360
671
  (stoppedByUser || hasTerminalMessage)
361
672
  ? "ready"
362
673
  : sdkStatus;
363
674
  const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
675
+ const connectionError = sdkConnectionError ?? undefined;
676
+ const connectionStatus: ChatConnectionStatus = connectionError
677
+ ? connectionError.code === 1008 ? "rejected" : "error"
678
+ : agent.identified ? "connected" : "connecting";
679
+ const runtimeError = dispatchError ?? sdkError ?? connectionError;
680
+ const runtimeErrorMessage = runtimeError instanceof Error
681
+ ? runtimeError.message
682
+ : runtimeError;
683
+ const presentationError = isServerStreaming || isRecovering ||
684
+ (latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
685
+ ? undefined
686
+ : runtimeError;
364
687
  // RPC admissions bypass useChat's request lifecycle. Project the durable
365
688
  // Turn here so presentation stays correct without a second send path.
366
- const authoritativeStatus =
367
- normalizedSdkStatus === "ready" &&
689
+ const authoritativeStatus = isServerStreaming
690
+ ? "streaming"
691
+ : normalizedSdkStatus === "ready" &&
368
692
  !isRecovering &&
369
693
  approvals?.length === 0 &&
370
694
  authoritativeTurn?.activeSubmissionId
@@ -372,13 +696,57 @@ export function useUniversalAgentChat(
372
696
  ? "streaming"
373
697
  : "submitted"
374
698
  : normalizedSdkStatus;
699
+ const hydratingTurnActive = facetState === undefined &&
700
+ connection.initialTurnActive === true;
375
701
  const status = dispatchError
376
702
  ? "error"
377
- : optimisticMessages.length > 0 && authoritativeStatus === "ready"
703
+ : (optimisticMessages.length > 0 || hydratingTurnActive) &&
704
+ authoritativeStatus === "ready"
378
705
  ? "submitted"
379
706
  : authoritativeStatus;
380
707
  const messagesRef = useRef(messages);
381
708
  messagesRef.current = messages;
709
+ // SDK callback identities can change without a new recovery request.
710
+ const replayActionsRef = useRef({ setMessages, clearError });
711
+ replayActionsRef.current = { setMessages, clearError };
712
+ useEffect(() => {
713
+ const activeMessageId = facetState?.turn?.activeMessageId;
714
+ if (
715
+ !activeMessageId ||
716
+ sdkStatus !== "error" ||
717
+ sdkIsRecovering ||
718
+ isServerStreaming
719
+ ) return;
720
+ const url = chatAgent.getHttpUrl();
721
+ if (!url) return;
722
+ const baseline = messagesRef.current;
723
+ let cancelled = false;
724
+ void loadInitialMessages(url, connection.credentials, "full")
725
+ .then((snapshot) => {
726
+ if (cancelled) return;
727
+ // The SDK protects its own snapshots while streaming. This HTTP
728
+ // fallback must also reject a response older than the current store.
729
+ const actions = replayActionsRef.current;
730
+ let applied = false;
731
+ actions.setMessages((current) => {
732
+ if (current !== baseline) return current;
733
+ applied = true;
734
+ return snapshot;
735
+ });
736
+ if (applied) actions.clearError();
737
+ })
738
+ .catch(() => undefined);
739
+ return () => {
740
+ cancelled = true;
741
+ };
742
+ }, [
743
+ chatAgent,
744
+ connection.credentials,
745
+ facetState?.turn?.activeMessageId,
746
+ isServerStreaming,
747
+ sdkIsRecovering,
748
+ sdkStatus,
749
+ ]);
382
750
  const turnTimingRef = useRef<ChatTurnTiming | null>(null);
383
751
 
384
752
  useEffect(() => {
@@ -386,7 +754,8 @@ export function useUniversalAgentChat(
386
754
  if (!timing) return;
387
755
  const assistant = messages.find((message) =>
388
756
  message.role === "assistant" &&
389
- !timing.baselineAssistantIds.has(message.id)
757
+ !timing.baselineAssistantIds.has(message.id) &&
758
+ (!timing.submissionId || message.metadata?.turnId === timing.submissionId)
390
759
  );
391
760
  if (assistant) markChatTurnPhase(timing, "streamStart");
392
761
  if (
@@ -398,9 +767,18 @@ export function useUniversalAgentChat(
398
767
  }
399
768
  if (status === "ready" && timing.streamStart !== undefined) {
400
769
  if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
770
+ else timing.ready ??= now();
401
771
  turnTimingRef.current = null;
772
+ const metrics = clientPerformanceMetrics(timing);
773
+ if (connection.performanceRpc && timing.submissionId && metrics) {
774
+ void agentRef.current.call(connection.performanceRpc, [{
775
+ submissionId: timing.submissionId,
776
+ messageId: timing.messageId,
777
+ ...metrics,
778
+ }]).catch(() => {});
779
+ }
402
780
  }
403
- }, [messages, status]);
781
+ }, [connection.performanceRpc, messages, status]);
404
782
 
405
783
  useEffect(() => {
406
784
  if (optimisticMessages.length === 0 || messages.length === 0) return;
@@ -466,7 +844,8 @@ export function useUniversalAgentChat(
466
844
  isToolContinuation;
467
845
  const pendingLocalTurn = optimisticMessages.length > 0 &&
468
846
  (status === "submitted" || status === "streaming");
469
- const turnActive = Boolean(turn?.activeSubmissionId) ||
847
+ const turnActive = hydratingTurnActive ||
848
+ Boolean(turn?.activeSubmissionId) ||
470
849
  pendingLocalTurn ||
471
850
  (authoritativeTurn === undefined && sdkTurnActive);
472
851
  const canSteer = turnActive &&
@@ -485,17 +864,56 @@ export function useUniversalAgentChat(
485
864
  const dispatchMessage = useCallback(async (
486
865
  message: ChatMessage,
487
866
  delivery: MessageDelivery,
488
- ): Promise<MessageDispatchReceipt> =>
489
- agentRef.current.call<MessageDispatchReceipt>(
867
+ ): Promise<MessageDispatchReceipt> => {
868
+ const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
490
869
  "dispatchMessage",
491
870
  [message, delivery],
492
- ), []);
871
+ );
872
+ try {
873
+ return await dispatch();
874
+ } catch (error) {
875
+ // The server deduplicates dispatches by message.id, so replay only the
876
+ // transport failures that mean admission may already have succeeded.
877
+ const retryable = error instanceof Error && (
878
+ /^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
879
+ (error.message === "Connection closed" && agentRef.current.shouldReconnect)
880
+ );
881
+ if (!retryable) throw error;
882
+ await agentRef.current.ready;
883
+ return dispatch();
884
+ }
885
+ }, []);
493
886
 
494
887
  const submitMessage = useCallback(async (
495
888
  message: ChatMessage,
496
889
  delivery: MessageDelivery,
497
890
  projection: "message" | "queue",
498
891
  ): Promise<boolean> => {
892
+ if (projection === "message" && delivery === "steer") {
893
+ const activeSubmissionId = authoritativeTurn?.activeSubmissionId;
894
+ const activeAssistants = messagesRef.current.filter((candidate) =>
895
+ candidate.role === "assistant" &&
896
+ candidate.metadata?.turnId === activeSubmissionId
897
+ );
898
+ const assistantMessageId = activeAssistants[0]?.id ??
899
+ authoritativeTurn?.activeMessageId;
900
+ if (assistantMessageId) {
901
+ message = {
902
+ ...message,
903
+ metadata: {
904
+ ...message.metadata,
905
+ optimisticSteer: {
906
+ assistantMessageId,
907
+ afterStep: activeAssistants.reduce((total, assistant) =>
908
+ total + assistant.parts.filter(
909
+ ({ type }) => type === "step-start",
910
+ ).length, 0),
911
+ },
912
+ },
913
+ };
914
+ }
915
+ }
916
+ const stopRevision = stopRevisionRef.current;
499
917
  setDispatchError(undefined);
500
918
  clearError();
501
919
  failedDispatchRef.current = undefined;
@@ -513,7 +931,6 @@ export function useUniversalAgentChat(
513
931
  };
514
932
  }
515
933
  if (projection === "message") {
516
- activeSubmissionIdRef.current = undefined;
517
934
  setOptimisticMessages((current) =>
518
935
  current.some(({ id }) => id === message.id)
519
936
  ? current
@@ -548,7 +965,7 @@ export function useUniversalAgentChat(
548
965
  markChatTurnPhase(turnTimingRef.current, "rpcReceipt");
549
966
  }
550
967
  if (receipt.kind === "rejected") {
551
- if (projection === "queue") rollback();
968
+ rollback();
552
969
  if (turnTimingRef.current?.messageId === message.id) {
553
970
  turnTimingRef.current = null;
554
971
  }
@@ -558,9 +975,49 @@ export function useUniversalAgentChat(
558
975
  return false;
559
976
  }
560
977
  if (projection === "message") {
561
- activeSubmissionIdRef.current = receipt.kind === "queued"
978
+ const submissionId = receipt.kind === "queued"
562
979
  ? receipt.submission.submissionId
563
980
  : receipt.submissionId;
981
+ if (stopRevision !== stopRevisionRef.current) {
982
+ await agentRef.current.call<{ ok: boolean }>(
983
+ "cancelSubmissionById",
984
+ [submissionId, USER_STOP_REASON],
985
+ );
986
+ rollback();
987
+ return true;
988
+ }
989
+ if (
990
+ delivery === "steer" && receipt.kind === "accepted" &&
991
+ !message.metadata?.optimisticSteer
992
+ ) {
993
+ const activeAssistant = messagesRef.current.findLast((candidate) =>
994
+ candidate.role === "assistant" &&
995
+ candidate.metadata?.turnId === submissionId
996
+ );
997
+ const assistantMessageId = activeAssistant?.id ??
998
+ (authoritativeTurn?.activeSubmissionId === submissionId
999
+ ? authoritativeTurn.activeMessageId
1000
+ : undefined);
1001
+ if (assistantMessageId) {
1002
+ const afterStep = activeAssistant?.parts.filter(
1003
+ ({ type }) => type === "step-start",
1004
+ ).length ?? 0;
1005
+ setOptimisticMessages((current) => current.map((optimistic) =>
1006
+ optimistic.id === message.id
1007
+ ? {
1008
+ ...optimistic,
1009
+ metadata: {
1010
+ ...optimistic.metadata,
1011
+ optimisticSteer: { assistantMessageId, afterStep },
1012
+ },
1013
+ }
1014
+ : optimistic
1015
+ ));
1016
+ }
1017
+ }
1018
+ if (turnTimingRef.current?.messageId === message.id) {
1019
+ turnTimingRef.current.submissionId = submissionId;
1020
+ }
564
1021
  }
565
1022
  if (projection === "queue") {
566
1023
  if (receipt.kind !== "queued" || receipt.position < 1) {
@@ -579,7 +1036,7 @@ export function useUniversalAgentChat(
579
1036
  }
580
1037
  return true;
581
1038
  } catch (cause) {
582
- if (projection === "queue") rollback();
1039
+ rollback();
583
1040
  if (turnTimingRef.current?.messageId === message.id) {
584
1041
  turnTimingRef.current = null;
585
1042
  }
@@ -590,14 +1047,21 @@ export function useUniversalAgentChat(
590
1047
  );
591
1048
  return false;
592
1049
  }
593
- }, [chatId, clearError, dispatchMessage]);
1050
+ }, [
1051
+ authoritativeTurn?.activeMessageId,
1052
+ authoritativeTurn?.activeSubmissionId,
1053
+ chatId,
1054
+ clearError,
1055
+ dispatchMessage,
1056
+ ]);
594
1057
 
595
1058
  const sendText = useCallback(async (
596
1059
  text: string,
597
1060
  files?: readonly FileUIPart[],
598
1061
  capabilities?: readonly RequestedCapability[],
1062
+ messageId?: string,
599
1063
  ): Promise<boolean> => {
600
- const message = createChatMessage(text, files, capabilities);
1064
+ const message = createChatMessage(text, files, capabilities, messageId);
601
1065
  return message
602
1066
  ? submitMessage(message, "enqueue", "message")
603
1067
  : false;
@@ -678,29 +1142,49 @@ export function useUniversalAgentChat(
678
1142
  }, []);
679
1143
 
680
1144
  const stop = useCallback(async () => {
1145
+ stopRevisionRef.current += 1;
681
1146
  const requestId = authoritativeTurn?.activeRequestId;
682
- const submissionId = authoritativeTurn?.activeSubmissionId ??
683
- activeSubmissionIdRef.current;
1147
+ const submissionId = authoritativeTurn?.activeSubmissionId;
1148
+ const beforeStop = messagesRef.current;
1149
+ const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
1150
+ const assistantIndex = beforeStop.findLastIndex(
1151
+ ({ role }, index) => index > userIndex && role === "assistant",
1152
+ );
1153
+ const marker = {
1154
+ completedAt: Date.now(),
1155
+ interruptedByUser: true,
1156
+ turnStatus: "aborted",
1157
+ } as const;
1158
+ setMessages(assistantIndex < 0
1159
+ ? [...beforeStop, {
1160
+ id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
1161
+ role: "assistant",
1162
+ metadata: marker,
1163
+ parts: [],
1164
+ }]
1165
+ : beforeStop.map((message, index) =>
1166
+ index === assistantIndex
1167
+ ? { ...message, metadata: { ...message.metadata, ...marker } }
1168
+ : message
1169
+ ));
1170
+ const rollback = () => setMessages(beforeStop);
684
1171
  setDispatchError(undefined);
685
1172
  try {
1173
+ await agentRef.current.call<{ ok: boolean }>(
1174
+ "stopAllSubmissions",
1175
+ [USER_STOP_REASON],
1176
+ );
1177
+ setOptimisticMessages([]);
1178
+ setOptimisticQueued([]);
686
1179
  if (requestId) {
687
1180
  agentRef.current.send(JSON.stringify({
688
1181
  type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
689
1182
  id: requestId,
690
1183
  }));
691
- return;
1184
+ await stopChat();
692
1185
  }
693
- const result = submissionId
694
- ? await agentRef.current.call<{ ok: boolean }>(
695
- "cancelSubmissionById",
696
- [submissionId, USER_STOP_REASON],
697
- )
698
- : await agentRef.current.call<{ ok: boolean }>(
699
- "stopTurn",
700
- [undefined, USER_STOP_REASON],
701
- );
702
- if (!result.ok) setDispatchError("The active Turn could not be stopped");
703
1186
  } catch (cause) {
1187
+ rollback();
704
1188
  setDispatchError(
705
1189
  cause instanceof Error ? cause.message : String(cause),
706
1190
  );
@@ -708,6 +1192,7 @@ export function useUniversalAgentChat(
708
1192
  }, [
709
1193
  authoritativeTurn?.activeRequestId,
710
1194
  authoritativeTurn?.activeSubmissionId,
1195
+ stopChat,
711
1196
  ]);
712
1197
 
713
1198
  const retry = useCallback(() => {
@@ -736,6 +1221,14 @@ export function useUniversalAgentChat(
736
1221
  return false;
737
1222
  }
738
1223
  }, []);
1224
+ const decideApproval = useCallback(
1225
+ (executionId: string, decision: ApprovalDecision) =>
1226
+ agentRef.current.call<{ ok: boolean }>(
1227
+ "decideApproval",
1228
+ [executionId, decision],
1229
+ ),
1230
+ [],
1231
+ );
739
1232
 
740
1233
  // 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
741
1234
  // 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
@@ -743,6 +1236,24 @@ export function useUniversalAgentChat(
743
1236
  () => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
744
1237
  [],
745
1238
  );
1239
+ const getRuntimeBinding = useCallback(
1240
+ <Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
1241
+ [],
1242
+ );
1243
+ const readChatMetadata = useCallback(
1244
+ <Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
1245
+ [],
1246
+ );
1247
+ const renameChat = useCallback(
1248
+ <Metadata,>(title: string) =>
1249
+ agentRef.current.call<Metadata>("renameChat", [title]),
1250
+ [],
1251
+ );
1252
+ const rebindRuntime = useCallback(
1253
+ <Input, Result,>(input: Input) =>
1254
+ agentRef.current.call<Result>("rebindRuntime", [input]),
1255
+ [],
1256
+ );
746
1257
  const updateConfig = useCallback(
747
1258
  <Change,>(command: unknown) =>
748
1259
  agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
@@ -761,15 +1272,21 @@ export function useUniversalAgentChat(
761
1272
 
762
1273
  return {
763
1274
  respondToolInteraction,
1275
+ decideApproval,
764
1276
  getRuntimeAssembly,
1277
+ getRuntimeBinding,
1278
+ readChatMetadata,
1279
+ renameChat,
1280
+ rebindRuntime,
765
1281
  updateConfig,
766
1282
  reloadRuntime,
767
1283
  messages: visibleMessages,
768
1284
  status,
1285
+ connectionStatus,
1286
+ connectionError,
769
1287
  runtimeLoad: facetState?.runtimeLoad,
770
1288
  isStreaming,
771
- error: dispatchError ?? sdkError ??
772
- connectionError ?? undefined,
1289
+ error: presentationError,
773
1290
  sendText,
774
1291
  steerText,
775
1292
  enqueueText,