@springbrand/chat-client 0.1.3-alpha.9 → 0.3.0-alpha.2

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.9",
3
+ "version": "0.3.0-alpha.2",
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.12.0",
17
+ "agents": "^0.23.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.12.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.2.0-alpha.13"
30
+ "@springbrand/agent-runtime": "0.3.0-alpha.3"
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,18 +168,83 @@ 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 & (
153
- | { inboxName: string; basePath?: never }
154
- | { inboxName?: never; basePath?: string }
183
+ | { inboxName: string; basePath?: never; sessionBasePath?: never }
184
+ | { inboxName?: never; basePath?: string; sessionBasePath?: never }
185
+ | { inboxName?: never; basePath?: never; sessionBasePath: string }
155
186
  );
156
187
 
188
+ type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
189
+ type UniversalAgentChatContextValue = {
190
+ chatId: string;
191
+ connection: ChatConnectionOptions;
192
+ agent: SessionAgentConnection;
193
+ chatAgent: SessionAgentConnection;
194
+ };
195
+
196
+ const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
197
+
198
+ export function UniversalAgentChatProvider({
199
+ chatId,
200
+ connection = {},
201
+ fallback = null,
202
+ children,
203
+ }: {
204
+ chatId: string;
205
+ connection?: ChatConnectionOptions;
206
+ fallback?: ReactNode;
207
+ children: ReactNode;
208
+ }) {
209
+ const inboxAgent = connection.inboxAgent ?? "Inbox";
210
+ const routed = connection.sessionBasePath !== undefined;
211
+ const agent = useAgent<RuntimeState>({
212
+ agent: routed ? connection.sessionAgent ?? "UniversalAgent" : inboxAgent,
213
+ ...(routed
214
+ ? { basePath: connection.sessionBasePath }
215
+ : connection.inboxName === undefined
216
+ ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
217
+ : { name: connection.inboxName }),
218
+ ...(connection.host === undefined ? {} : { host: connection.host }),
219
+ ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
220
+ ...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
221
+ connectionTimeout: 6_000,
222
+ ...(routed ? {} : {
223
+ sub: [{
224
+ agent: connection.sessionAgent ?? "UniversalAgent",
225
+ name: chatId,
226
+ }],
227
+ }),
228
+ });
229
+ const chatAgent = useMemo(
230
+ () => createSafeUIMessageAgentConnection(agent),
231
+ [agent],
232
+ );
233
+ const value = useMemo(
234
+ () => ({ chatId, connection, agent, chatAgent }),
235
+ [agent, chatAgent, chatId, connection],
236
+ );
237
+ return createElement(
238
+ UniversalAgentChatContext.Provider,
239
+ { value },
240
+ createElement(Suspense, { fallback }, children),
241
+ );
242
+ }
243
+
157
244
  type ChatTurnTiming = {
158
245
  chatId: string;
159
246
  messageId: string;
247
+ submissionId?: string;
160
248
  baselineAssistantIds: Set<string>;
161
249
  click: number;
162
250
  rpcReceipt?: number;
@@ -165,6 +253,37 @@ type ChatTurnTiming = {
165
253
  ready?: number;
166
254
  };
167
255
 
256
+ type ClientPerformanceMetrics = {
257
+ clickToRpcMs: number;
258
+ rpcToStreamMs: number;
259
+ streamToFirstTextMs?: number;
260
+ firstTextToReadyMs?: number;
261
+ ttftMs?: number;
262
+ totalMs: number;
263
+ };
264
+
265
+ export function clientPerformanceMetrics(
266
+ timing: ChatTurnTiming,
267
+ ): ClientPerformanceMetrics | null {
268
+ if (
269
+ timing.rpcReceipt === undefined ||
270
+ timing.streamStart === undefined ||
271
+ timing.ready === undefined
272
+ ) return null;
273
+ return {
274
+ clickToRpcMs: timing.rpcReceipt - timing.click,
275
+ rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
276
+ ...(timing.firstText === undefined
277
+ ? {}
278
+ : {
279
+ streamToFirstTextMs: timing.firstText - timing.streamStart,
280
+ firstTextToReadyMs: timing.ready - timing.firstText,
281
+ ttftMs: timing.firstText - timing.click,
282
+ }),
283
+ totalMs: timing.ready - timing.click,
284
+ };
285
+ }
286
+
168
287
  const CHAT_TURN_SEGMENTS = {
169
288
  rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
170
289
  streamStart: [
@@ -210,6 +329,7 @@ function createChatMessage(
210
329
  text: string,
211
330
  files?: readonly FileUIPart[],
212
331
  capabilities: readonly RequestedCapability[] = [],
332
+ messageId?: string,
213
333
  ): ChatMessage | null {
214
334
  const normalizedText = text.trim();
215
335
  if (!normalizedText && !files?.length) return null;
@@ -222,7 +342,7 @@ function createChatMessage(
222
342
  return true;
223
343
  });
224
344
  return {
225
- id: nanoid(),
345
+ id: messageId ?? nanoid(),
226
346
  role: "user",
227
347
  parts: [
228
348
  ...(files ?? []),
@@ -243,10 +363,204 @@ export function mergeOptimisticMessages<T extends UIMessage>(
243
363
  messages: T[],
244
364
  optimistic: T[],
245
365
  ): T[] {
246
- if (optimistic.length === 0) return messages;
247
366
  const ids = new Set(messages.map(({ id }) => id));
248
367
  const pending = optimistic.filter(({ id }) => !ids.has(id));
249
- return pending.length === 0 ? messages : [...messages, ...pending];
368
+ const assistantsByTurn = new Map<string, T[]>();
369
+ for (const message of messages) {
370
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
371
+ if (message.role !== "assistant" || typeof turnId !== "string") continue;
372
+ const assistants = assistantsByTurn.get(turnId) ?? [];
373
+ assistants.push(message);
374
+ assistantsByTurn.set(turnId, assistants);
375
+ }
376
+ const assistantPosition = new Map<string, {
377
+ assistantMessageId: string;
378
+ stepOffset: number;
379
+ }>();
380
+ for (const assistants of assistantsByTurn.values()) {
381
+ let stepOffset = 0;
382
+ for (const assistant of assistants) {
383
+ assistantPosition.set(assistant.id, {
384
+ assistantMessageId: assistants[0]!.id,
385
+ stepOffset,
386
+ });
387
+ stepOffset += assistant.parts.filter(
388
+ ({ type }) => type === "step-start",
389
+ ).length;
390
+ }
391
+ }
392
+ const normalizeAnchor = (anchor: NonNullable<
393
+ ChatMessageMetadata["optimisticSteer"]
394
+ >) => {
395
+ const position = assistantPosition.get(anchor.assistantMessageId);
396
+ return position
397
+ ? {
398
+ assistantMessageId: position.assistantMessageId,
399
+ afterStep: position.stepOffset + anchor.afterStep,
400
+ }
401
+ : anchor;
402
+ };
403
+ const logicalMessages: T[] = [];
404
+ const logicalAssistantIndex = new Map<string, number>();
405
+ for (const message of messages) {
406
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
407
+ if (message.role !== "assistant" || typeof turnId !== "string") {
408
+ logicalMessages.push(message);
409
+ continue;
410
+ }
411
+ const existingIndex = logicalAssistantIndex.get(turnId);
412
+ if (existingIndex === undefined) {
413
+ logicalAssistantIndex.set(turnId, logicalMessages.length);
414
+ logicalMessages.push(message);
415
+ continue;
416
+ }
417
+ const existing = logicalMessages[existingIndex]!;
418
+ logicalMessages[existingIndex] = {
419
+ ...existing,
420
+ parts: [...existing.parts, ...message.parts],
421
+ metadata: {
422
+ ...(existing.metadata as Record<string, unknown> | undefined),
423
+ ...(message.metadata as Record<string, unknown> | undefined),
424
+ },
425
+ } as T;
426
+ }
427
+ const authoritativeAnchors = messages.flatMap((message, messageIndex) => {
428
+ const anchor = (message.metadata as ChatMessageMetadata | undefined)
429
+ ?.optimisticSteer;
430
+ if (anchor) return [{ message, anchor: normalizeAnchor(anchor) }];
431
+ if (message.role !== "user") return [];
432
+ const turnId = (message.metadata as ChatMessageMetadata | undefined)?.turnId;
433
+ const assistants = typeof turnId === "string"
434
+ ? assistantsByTurn.get(turnId)
435
+ : undefined;
436
+ if (!assistants || assistants.length === 0) return [];
437
+ const openingMessageId = (assistants[0]!.metadata as
438
+ | ChatMessageMetadata
439
+ | undefined)?.userMessageId;
440
+ if (message.id === openingMessageId) return [];
441
+ const preceding = messages.slice(0, messageIndex).filter((candidate) =>
442
+ candidate.role === "assistant" &&
443
+ (candidate.metadata as ChatMessageMetadata | undefined)?.turnId === turnId
444
+ );
445
+ return [{
446
+ message,
447
+ anchor: {
448
+ assistantMessageId: assistants[0]!.id,
449
+ afterStep: preceding.reduce((total, assistant) =>
450
+ total + assistant.parts.filter(
451
+ ({ type }) => type === "step-start",
452
+ ).length, 0),
453
+ },
454
+ }];
455
+ });
456
+ const pendingAnchors = pending.flatMap((message) => {
457
+ const anchor = (message.metadata as ChatMessageMetadata | undefined)
458
+ ?.optimisticSteer;
459
+ return anchor ? [{ message, anchor: normalizeAnchor(anchor) }] : [];
460
+ });
461
+ const anchoredSteers = [...authoritativeAnchors, ...pendingAnchors];
462
+ if (pending.length === 0 && anchoredSteers.length === 0) return messages;
463
+ const anchoredIds = new Set(
464
+ anchoredSteers.map(({ message }) => message.id),
465
+ );
466
+ const pendingById = new Map(
467
+ pending
468
+ .filter(({ id }) => !anchoredIds.has(id))
469
+ .map((message) => [message.id, message]),
470
+ );
471
+ const withPrompts = logicalMessages
472
+ .filter(({ id }) => !anchoredIds.has(id))
473
+ .flatMap((message) => {
474
+ const userMessageId = message.role === "assistant"
475
+ ? (message.metadata as ChatMessageMetadata | undefined)?.userMessageId
476
+ : undefined;
477
+ if (typeof userMessageId !== "string") return [message];
478
+ const prompt = pendingById.get(userMessageId);
479
+ if (!prompt) return [message];
480
+ pendingById.delete(userMessageId);
481
+ return [prompt, message];
482
+ });
483
+ const unmatchedPrompts = pending.filter(({ id }) => pendingById.has(id));
484
+ if (anchoredSteers.length === 0) {
485
+ return [
486
+ ...withPrompts,
487
+ ...unmatchedPrompts,
488
+ ];
489
+ }
490
+ const targetAssistantIds = new Set(
491
+ anchoredSteers.map(({ anchor }) => anchor.assistantMessageId),
492
+ );
493
+ const firstTarget = withPrompts.findIndex(({ id, role }) =>
494
+ role === "assistant" && targetAssistantIds.has(id)
495
+ );
496
+ const base = firstTarget < 0
497
+ ? [...withPrompts, ...unmatchedPrompts]
498
+ : [
499
+ ...withPrompts.slice(0, firstTarget),
500
+ ...unmatchedPrompts,
501
+ ...withPrompts.slice(firstTarget),
502
+ ];
503
+ const byAssistant = new Map<string, typeof anchoredSteers>();
504
+ for (const anchored of anchoredSteers) {
505
+ const group = byAssistant.get(anchored.anchor.assistantMessageId) ?? [];
506
+ group.push(anchored);
507
+ byAssistant.set(anchored.anchor.assistantMessageId, group);
508
+ }
509
+ const placed = new Set<string>();
510
+ const merged = base.flatMap((message) => {
511
+ if (message.role !== "assistant") return [message];
512
+ const steers = byAssistant.get(message.id);
513
+ if (!steers) return [message];
514
+
515
+ const projected: T[] = [];
516
+ const stepStarts = message.parts.flatMap((part, index) =>
517
+ part.type === "step-start" ? [index] : []
518
+ );
519
+ let cursor = 0;
520
+ let minimumBoundary = 1;
521
+ for (let index = 0; index < steers.length;) {
522
+ const boundary = Math.max(
523
+ minimumBoundary,
524
+ steers[index]!.anchor.afterStep,
525
+ );
526
+ minimumBoundary = boundary;
527
+ let end = index + 1;
528
+ while (
529
+ end < steers.length &&
530
+ steers[end]!.anchor.afterStep <= boundary
531
+ ) {
532
+ end += 1;
533
+ }
534
+ const segmentEnd = stepStarts[boundary] ?? message.parts.length;
535
+ if (segmentEnd > cursor) {
536
+ projected.push({
537
+ ...message,
538
+ id: `${message.id}:segment:${projected.length}`,
539
+ parts: message.parts.slice(cursor, segmentEnd),
540
+ });
541
+ }
542
+ for (const anchored of steers.slice(index, end)) {
543
+ projected.push(anchored.message);
544
+ placed.add(anchored.message.id);
545
+ }
546
+ cursor = segmentEnd;
547
+ index = end;
548
+ }
549
+ if (cursor < message.parts.length) {
550
+ projected.push({
551
+ ...message,
552
+ id: `${message.id}:segment:${projected.length}`,
553
+ parts: message.parts.slice(cursor),
554
+ });
555
+ }
556
+ return projected;
557
+ });
558
+ return [
559
+ ...merged,
560
+ ...anchoredSteers
561
+ .filter(({ message }) => !placed.has(message.id))
562
+ .map(({ message }) => message),
563
+ ];
250
564
  }
251
565
 
252
566
  function queuedPreview(message: ChatMessage): string {
@@ -260,10 +574,12 @@ function queuedPreview(message: ChatMessage): string {
260
574
  function loadInitialMessages(
261
575
  url: string | undefined,
262
576
  credentials: RequestCredentials | undefined,
577
+ view: "hydration" | "full" = "hydration",
263
578
  ): Promise<ChatMessage[]> {
264
579
  if (!url) return Promise.resolve([]);
265
580
  const messagesUrl = new URL(url);
266
581
  messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
582
+ if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
267
583
  return getAgentMessages({
268
584
  url: messagesUrl.toString(),
269
585
  ...(credentials === undefined ? {} : { credentials }),
@@ -281,10 +597,12 @@ function loadInitialMessages(
281
597
  * sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
282
598
  * 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
283
599
  */
284
- export function useUniversalAgentChat(
285
- chatId: string,
286
- connection: ChatConnectionOptions = {},
287
- ): ChatRuntime {
600
+ export function useUniversalAgentChat(): ChatRuntime {
601
+ const context = useContext(UniversalAgentChatContext);
602
+ if (!context) {
603
+ throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
604
+ }
605
+ const { chatId, connection, agent, chatAgent } = context;
288
606
  const [optimisticMessages, setOptimisticMessages] =
289
607
  useState<ChatMessage[]>([]);
290
608
  const [optimisticQueued, setOptimisticQueued] =
@@ -292,25 +610,7 @@ export function useUniversalAgentChat(
292
610
  const [dispatchError, setDispatchError] = useState<string>();
293
611
  const [canRetry, setCanRetry] = useState(false);
294
612
  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
-
613
+ const stopRevisionRef = useRef(0);
314
614
  // 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
315
615
  // AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
316
616
  // 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
@@ -326,27 +626,32 @@ export function useUniversalAgentChat(
326
626
  isStreaming,
327
627
  error,
328
628
  clearError,
629
+ setMessages,
630
+ stop: stopChat,
329
631
  // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
330
632
  // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
331
633
  isServerStreaming,
332
- isRecovering,
634
+ isRecovering: sdkIsRecovering,
333
635
  isToolContinuation,
334
- connectionError,
636
+ connectionError: sdkConnectionError,
335
637
  } = useAgentChat<RuntimeState, ChatMessage>({
336
638
  agent: chatAgent,
337
- getInitialMessages: ({ url }) =>
338
- loadInitialMessages(url, connection.credentials),
639
+ getInitialMessages: connection.skipInitialMessages
640
+ ? null
641
+ : ({ url }) => loadInitialMessages(url, connection.credentials),
339
642
  ...(connection.credentials === undefined
340
643
  ? {}
341
644
  : { credentials: connection.credentials }),
342
645
  syncMessagesToServer: false,
343
- throttle: 100,
344
646
  });
647
+ // The SDK keeps its advisory recovery flag until terminal settlement; a
648
+ // live server stream proves the recovered Turn is executing again.
649
+ const isRecovering = sdkIsRecovering && !isServerStreaming;
345
650
  const visibleMessages = useMemo(
346
651
  () => mergeOptimisticMessages(messages, optimisticMessages),
347
652
  [messages, optimisticMessages],
348
653
  );
349
- const authoritativeTurn = facetState?.turn;
654
+ const reportedTurn = facetState?.turn;
350
655
  const stoppedByUser = error?.message === USER_STOP_REASON;
351
656
  const latestMessage = visibleMessages.at(-1);
352
657
  const latestTurnStatus = latestMessage?.role === "assistant"
@@ -356,15 +661,40 @@ export function useUniversalAgentChat(
356
661
  latestTurnStatus === "error" ||
357
662
  latestTurnStatus === "aborted" ||
358
663
  latestTurnStatus === "skipped";
664
+ // Messages and Agent state arrive in separate frames. A terminal message for
665
+ // the same Submission is newer than a still-active state projection; a
666
+ // different active ID belongs to the next Turn and must remain live.
667
+ const authoritativeTurn = reportedTurn &&
668
+ hasTerminalMessage &&
669
+ latestMessage?.metadata?.turnId === reportedTurn.activeSubmissionId
670
+ ? {
671
+ steerable: false,
672
+ hasPendingSteer: false,
673
+ queued: reportedTurn.queued,
674
+ }
675
+ : reportedTurn;
359
676
  const normalizedSdkStatus = sdkStatus === "error" &&
360
677
  (stoppedByUser || hasTerminalMessage)
361
678
  ? "ready"
362
679
  : sdkStatus;
363
680
  const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
681
+ const connectionError = sdkConnectionError ?? undefined;
682
+ const connectionStatus: ChatConnectionStatus = connectionError
683
+ ? connectionError.code === 1008 ? "rejected" : "error"
684
+ : agent.identified ? "connected" : "connecting";
685
+ const runtimeError = dispatchError ?? sdkError ?? connectionError;
686
+ const runtimeErrorMessage = runtimeError instanceof Error
687
+ ? runtimeError.message
688
+ : runtimeError;
689
+ const presentationError = isServerStreaming || isRecovering ||
690
+ (latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
691
+ ? undefined
692
+ : runtimeError;
364
693
  // RPC admissions bypass useChat's request lifecycle. Project the durable
365
694
  // Turn here so presentation stays correct without a second send path.
366
- const authoritativeStatus =
367
- normalizedSdkStatus === "ready" &&
695
+ const authoritativeStatus = isServerStreaming
696
+ ? "streaming"
697
+ : normalizedSdkStatus === "ready" &&
368
698
  !isRecovering &&
369
699
  approvals?.length === 0 &&
370
700
  authoritativeTurn?.activeSubmissionId
@@ -372,13 +702,57 @@ export function useUniversalAgentChat(
372
702
  ? "streaming"
373
703
  : "submitted"
374
704
  : normalizedSdkStatus;
705
+ const hydratingTurnActive = facetState === undefined &&
706
+ connection.initialTurnActive === true;
375
707
  const status = dispatchError
376
708
  ? "error"
377
- : optimisticMessages.length > 0 && authoritativeStatus === "ready"
709
+ : (optimisticMessages.length > 0 || hydratingTurnActive) &&
710
+ authoritativeStatus === "ready"
378
711
  ? "submitted"
379
712
  : authoritativeStatus;
380
713
  const messagesRef = useRef(messages);
381
714
  messagesRef.current = messages;
715
+ // SDK callback identities can change without a new recovery request.
716
+ const replayActionsRef = useRef({ setMessages, clearError });
717
+ replayActionsRef.current = { setMessages, clearError };
718
+ useEffect(() => {
719
+ const activeMessageId = facetState?.turn?.activeMessageId;
720
+ if (
721
+ !activeMessageId ||
722
+ sdkStatus !== "error" ||
723
+ sdkIsRecovering ||
724
+ isServerStreaming
725
+ ) return;
726
+ const url = chatAgent.getHttpUrl();
727
+ if (!url) return;
728
+ const baseline = messagesRef.current;
729
+ let cancelled = false;
730
+ void loadInitialMessages(url, connection.credentials, "full")
731
+ .then((snapshot) => {
732
+ if (cancelled) return;
733
+ // The SDK protects its own snapshots while streaming. This HTTP
734
+ // fallback must also reject a response older than the current store.
735
+ const actions = replayActionsRef.current;
736
+ let applied = false;
737
+ actions.setMessages((current) => {
738
+ if (current !== baseline) return current;
739
+ applied = true;
740
+ return snapshot;
741
+ });
742
+ if (applied) actions.clearError();
743
+ })
744
+ .catch(() => undefined);
745
+ return () => {
746
+ cancelled = true;
747
+ };
748
+ }, [
749
+ chatAgent,
750
+ connection.credentials,
751
+ facetState?.turn?.activeMessageId,
752
+ isServerStreaming,
753
+ sdkIsRecovering,
754
+ sdkStatus,
755
+ ]);
382
756
  const turnTimingRef = useRef<ChatTurnTiming | null>(null);
383
757
 
384
758
  useEffect(() => {
@@ -386,7 +760,8 @@ export function useUniversalAgentChat(
386
760
  if (!timing) return;
387
761
  const assistant = messages.find((message) =>
388
762
  message.role === "assistant" &&
389
- !timing.baselineAssistantIds.has(message.id)
763
+ !timing.baselineAssistantIds.has(message.id) &&
764
+ (!timing.submissionId || message.metadata?.turnId === timing.submissionId)
390
765
  );
391
766
  if (assistant) markChatTurnPhase(timing, "streamStart");
392
767
  if (
@@ -398,9 +773,18 @@ export function useUniversalAgentChat(
398
773
  }
399
774
  if (status === "ready" && timing.streamStart !== undefined) {
400
775
  if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
776
+ else timing.ready ??= now();
401
777
  turnTimingRef.current = null;
778
+ const metrics = clientPerformanceMetrics(timing);
779
+ if (connection.performanceRpc && timing.submissionId && metrics) {
780
+ void agentRef.current.call(connection.performanceRpc, [{
781
+ submissionId: timing.submissionId,
782
+ messageId: timing.messageId,
783
+ ...metrics,
784
+ }]).catch(() => {});
785
+ }
402
786
  }
403
- }, [messages, status]);
787
+ }, [connection.performanceRpc, messages, status]);
404
788
 
405
789
  useEffect(() => {
406
790
  if (optimisticMessages.length === 0 || messages.length === 0) return;
@@ -466,7 +850,8 @@ export function useUniversalAgentChat(
466
850
  isToolContinuation;
467
851
  const pendingLocalTurn = optimisticMessages.length > 0 &&
468
852
  (status === "submitted" || status === "streaming");
469
- const turnActive = Boolean(turn?.activeSubmissionId) ||
853
+ const turnActive = hydratingTurnActive ||
854
+ Boolean(turn?.activeSubmissionId) ||
470
855
  pendingLocalTurn ||
471
856
  (authoritativeTurn === undefined && sdkTurnActive);
472
857
  const canSteer = turnActive &&
@@ -485,17 +870,56 @@ export function useUniversalAgentChat(
485
870
  const dispatchMessage = useCallback(async (
486
871
  message: ChatMessage,
487
872
  delivery: MessageDelivery,
488
- ): Promise<MessageDispatchReceipt> =>
489
- agentRef.current.call<MessageDispatchReceipt>(
873
+ ): Promise<MessageDispatchReceipt> => {
874
+ const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
490
875
  "dispatchMessage",
491
876
  [message, delivery],
492
- ), []);
877
+ );
878
+ try {
879
+ return await dispatch();
880
+ } catch (error) {
881
+ // The server deduplicates dispatches by message.id, so replay only the
882
+ // transport failures that mean admission may already have succeeded.
883
+ const retryable = error instanceof Error && (
884
+ /^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
885
+ (error.message === "Connection closed" && agentRef.current.shouldReconnect)
886
+ );
887
+ if (!retryable) throw error;
888
+ await agentRef.current.ready;
889
+ return dispatch();
890
+ }
891
+ }, []);
493
892
 
494
893
  const submitMessage = useCallback(async (
495
894
  message: ChatMessage,
496
895
  delivery: MessageDelivery,
497
896
  projection: "message" | "queue",
498
897
  ): Promise<boolean> => {
898
+ if (projection === "message" && delivery === "steer") {
899
+ const activeSubmissionId = authoritativeTurn?.activeSubmissionId;
900
+ const activeAssistants = messagesRef.current.filter((candidate) =>
901
+ candidate.role === "assistant" &&
902
+ candidate.metadata?.turnId === activeSubmissionId
903
+ );
904
+ const assistantMessageId = activeAssistants[0]?.id ??
905
+ authoritativeTurn?.activeMessageId;
906
+ if (assistantMessageId) {
907
+ message = {
908
+ ...message,
909
+ metadata: {
910
+ ...message.metadata,
911
+ optimisticSteer: {
912
+ assistantMessageId,
913
+ afterStep: activeAssistants.reduce((total, assistant) =>
914
+ total + assistant.parts.filter(
915
+ ({ type }) => type === "step-start",
916
+ ).length, 0),
917
+ },
918
+ },
919
+ };
920
+ }
921
+ }
922
+ const stopRevision = stopRevisionRef.current;
499
923
  setDispatchError(undefined);
500
924
  clearError();
501
925
  failedDispatchRef.current = undefined;
@@ -513,7 +937,6 @@ export function useUniversalAgentChat(
513
937
  };
514
938
  }
515
939
  if (projection === "message") {
516
- activeSubmissionIdRef.current = undefined;
517
940
  setOptimisticMessages((current) =>
518
941
  current.some(({ id }) => id === message.id)
519
942
  ? current
@@ -548,7 +971,7 @@ export function useUniversalAgentChat(
548
971
  markChatTurnPhase(turnTimingRef.current, "rpcReceipt");
549
972
  }
550
973
  if (receipt.kind === "rejected") {
551
- if (projection === "queue") rollback();
974
+ rollback();
552
975
  if (turnTimingRef.current?.messageId === message.id) {
553
976
  turnTimingRef.current = null;
554
977
  }
@@ -558,9 +981,49 @@ export function useUniversalAgentChat(
558
981
  return false;
559
982
  }
560
983
  if (projection === "message") {
561
- activeSubmissionIdRef.current = receipt.kind === "queued"
984
+ const submissionId = receipt.kind === "queued"
562
985
  ? receipt.submission.submissionId
563
986
  : receipt.submissionId;
987
+ if (stopRevision !== stopRevisionRef.current) {
988
+ await agentRef.current.call<{ ok: boolean }>(
989
+ "cancelSubmissionById",
990
+ [submissionId, USER_STOP_REASON],
991
+ );
992
+ rollback();
993
+ return true;
994
+ }
995
+ if (
996
+ delivery === "steer" && receipt.kind === "accepted" &&
997
+ !message.metadata?.optimisticSteer
998
+ ) {
999
+ const activeAssistant = messagesRef.current.findLast((candidate) =>
1000
+ candidate.role === "assistant" &&
1001
+ candidate.metadata?.turnId === submissionId
1002
+ );
1003
+ const assistantMessageId = activeAssistant?.id ??
1004
+ (authoritativeTurn?.activeSubmissionId === submissionId
1005
+ ? authoritativeTurn.activeMessageId
1006
+ : undefined);
1007
+ if (assistantMessageId) {
1008
+ const afterStep = activeAssistant?.parts.filter(
1009
+ ({ type }) => type === "step-start",
1010
+ ).length ?? 0;
1011
+ setOptimisticMessages((current) => current.map((optimistic) =>
1012
+ optimistic.id === message.id
1013
+ ? {
1014
+ ...optimistic,
1015
+ metadata: {
1016
+ ...optimistic.metadata,
1017
+ optimisticSteer: { assistantMessageId, afterStep },
1018
+ },
1019
+ }
1020
+ : optimistic
1021
+ ));
1022
+ }
1023
+ }
1024
+ if (turnTimingRef.current?.messageId === message.id) {
1025
+ turnTimingRef.current.submissionId = submissionId;
1026
+ }
564
1027
  }
565
1028
  if (projection === "queue") {
566
1029
  if (receipt.kind !== "queued" || receipt.position < 1) {
@@ -579,7 +1042,7 @@ export function useUniversalAgentChat(
579
1042
  }
580
1043
  return true;
581
1044
  } catch (cause) {
582
- if (projection === "queue") rollback();
1045
+ rollback();
583
1046
  if (turnTimingRef.current?.messageId === message.id) {
584
1047
  turnTimingRef.current = null;
585
1048
  }
@@ -590,14 +1053,21 @@ export function useUniversalAgentChat(
590
1053
  );
591
1054
  return false;
592
1055
  }
593
- }, [chatId, clearError, dispatchMessage]);
1056
+ }, [
1057
+ authoritativeTurn?.activeMessageId,
1058
+ authoritativeTurn?.activeSubmissionId,
1059
+ chatId,
1060
+ clearError,
1061
+ dispatchMessage,
1062
+ ]);
594
1063
 
595
1064
  const sendText = useCallback(async (
596
1065
  text: string,
597
1066
  files?: readonly FileUIPart[],
598
1067
  capabilities?: readonly RequestedCapability[],
1068
+ messageId?: string,
599
1069
  ): Promise<boolean> => {
600
- const message = createChatMessage(text, files, capabilities);
1070
+ const message = createChatMessage(text, files, capabilities, messageId);
601
1071
  return message
602
1072
  ? submitMessage(message, "enqueue", "message")
603
1073
  : false;
@@ -678,29 +1148,49 @@ export function useUniversalAgentChat(
678
1148
  }, []);
679
1149
 
680
1150
  const stop = useCallback(async () => {
1151
+ stopRevisionRef.current += 1;
681
1152
  const requestId = authoritativeTurn?.activeRequestId;
682
- const submissionId = authoritativeTurn?.activeSubmissionId ??
683
- activeSubmissionIdRef.current;
1153
+ const submissionId = authoritativeTurn?.activeSubmissionId;
1154
+ const beforeStop = messagesRef.current;
1155
+ const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
1156
+ const assistantIndex = beforeStop.findLastIndex(
1157
+ ({ role }, index) => index > userIndex && role === "assistant",
1158
+ );
1159
+ const marker = {
1160
+ completedAt: Date.now(),
1161
+ interruptedByUser: true,
1162
+ turnStatus: "aborted",
1163
+ } as const;
1164
+ setMessages(assistantIndex < 0
1165
+ ? [...beforeStop, {
1166
+ id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
1167
+ role: "assistant",
1168
+ metadata: marker,
1169
+ parts: [],
1170
+ }]
1171
+ : beforeStop.map((message, index) =>
1172
+ index === assistantIndex
1173
+ ? { ...message, metadata: { ...message.metadata, ...marker } }
1174
+ : message
1175
+ ));
1176
+ const rollback = () => setMessages(beforeStop);
684
1177
  setDispatchError(undefined);
685
1178
  try {
1179
+ await agentRef.current.call<{ ok: boolean }>(
1180
+ "stopAllSubmissions",
1181
+ [USER_STOP_REASON],
1182
+ );
1183
+ setOptimisticMessages([]);
1184
+ setOptimisticQueued([]);
686
1185
  if (requestId) {
687
1186
  agentRef.current.send(JSON.stringify({
688
1187
  type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
689
1188
  id: requestId,
690
1189
  }));
691
- return;
1190
+ await stopChat();
692
1191
  }
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
1192
  } catch (cause) {
1193
+ rollback();
704
1194
  setDispatchError(
705
1195
  cause instanceof Error ? cause.message : String(cause),
706
1196
  );
@@ -708,6 +1198,7 @@ export function useUniversalAgentChat(
708
1198
  }, [
709
1199
  authoritativeTurn?.activeRequestId,
710
1200
  authoritativeTurn?.activeSubmissionId,
1201
+ stopChat,
711
1202
  ]);
712
1203
 
713
1204
  const retry = useCallback(() => {
@@ -736,6 +1227,14 @@ export function useUniversalAgentChat(
736
1227
  return false;
737
1228
  }
738
1229
  }, []);
1230
+ const decideApproval = useCallback(
1231
+ (executionId: string, decision: ApprovalDecision) =>
1232
+ agentRef.current.call<{ ok: boolean }>(
1233
+ "decideApproval",
1234
+ [executionId, decision],
1235
+ ),
1236
+ [],
1237
+ );
739
1238
 
740
1239
  // 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
741
1240
  // 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
@@ -743,6 +1242,24 @@ export function useUniversalAgentChat(
743
1242
  () => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
744
1243
  [],
745
1244
  );
1245
+ const getRuntimeBinding = useCallback(
1246
+ <Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
1247
+ [],
1248
+ );
1249
+ const readChatMetadata = useCallback(
1250
+ <Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
1251
+ [],
1252
+ );
1253
+ const renameChat = useCallback(
1254
+ <Metadata,>(title: string) =>
1255
+ agentRef.current.call<Metadata>("renameChat", [title]),
1256
+ [],
1257
+ );
1258
+ const rebindRuntime = useCallback(
1259
+ <Input, Result,>(input: Input) =>
1260
+ agentRef.current.call<Result>("rebindRuntime", [input]),
1261
+ [],
1262
+ );
746
1263
  const updateConfig = useCallback(
747
1264
  <Change,>(command: unknown) =>
748
1265
  agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
@@ -761,15 +1278,21 @@ export function useUniversalAgentChat(
761
1278
 
762
1279
  return {
763
1280
  respondToolInteraction,
1281
+ decideApproval,
764
1282
  getRuntimeAssembly,
1283
+ getRuntimeBinding,
1284
+ readChatMetadata,
1285
+ renameChat,
1286
+ rebindRuntime,
765
1287
  updateConfig,
766
1288
  reloadRuntime,
767
1289
  messages: visibleMessages,
768
1290
  status,
1291
+ connectionStatus,
1292
+ connectionError,
769
1293
  runtimeLoad: facetState?.runtimeLoad,
770
1294
  isStreaming,
771
- error: dispatchError ?? sdkError ??
772
- connectionError ?? undefined,
1295
+ error: presentationError,
773
1296
  sendText,
774
1297
  steerText,
775
1298
  enqueueText,