@springbrand/chat-client 0.1.3-alpha.4 → 0.1.3-alpha.41

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.4",
3
+ "version": "0.1.3-alpha.41",
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.19.0",
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.5"
30
+ "@springbrand/agent-runtime": "0.2.0-alpha.53"
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,
@@ -18,10 +31,19 @@ import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
18
31
  import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
19
32
 
20
33
  type ChatMessageMetadata = Record<string, unknown> & {
34
+ completedAt?: number;
35
+ error?: string;
36
+ interruptedByUser?: boolean;
21
37
  requestedCapabilities?: readonly RequestedCapability[];
38
+ turnId?: string;
22
39
  turnStatus?: string;
23
40
  };
24
41
  type ChatMessage = UIMessage<ChatMessageMetadata>;
42
+ export type ChatConnectionStatus =
43
+ | "connecting"
44
+ | "connected"
45
+ | "rejected"
46
+ | "error";
25
47
  type FailedDispatch = {
26
48
  message: ChatMessage;
27
49
  delivery: MessageDelivery;
@@ -73,6 +95,7 @@ type ChatQueuedSubmission = {
73
95
  type ChatTurn = {
74
96
  activeSubmissionId?: string;
75
97
  activeRequestId?: string;
98
+ activeMessageId?: string;
76
99
  recoveryAttempt?: number;
77
100
  recoveryMax?: number;
78
101
  recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
@@ -84,6 +107,8 @@ type ChatTurn = {
84
107
  export interface ChatRuntime {
85
108
  messages: ChatMessage[];
86
109
  status: ChatStatus;
110
+ connectionStatus: ChatConnectionStatus;
111
+ connectionError: AgentConnectionError | undefined;
87
112
  runtimeLoad: ChatRuntimeLoadState | undefined;
88
113
  isStreaming: boolean;
89
114
  error: string | Error | undefined;
@@ -115,6 +140,10 @@ export interface ChatRuntime {
115
140
  toolCallId: string,
116
141
  response: unknown,
117
142
  ) => Promise<boolean>;
143
+ decideApproval: (
144
+ executionId: string,
145
+ decision: ApprovalDecision,
146
+ ) => Promise<{ ok: boolean }>;
118
147
  /**
119
148
  * 读取该 Session facet 当前真正装配出来的 Runtime。
120
149
  *
@@ -124,6 +153,10 @@ export interface ChatRuntime {
124
153
  * 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
125
154
  */
126
155
  getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
156
+ getRuntimeBinding: <Binding = unknown>() => Promise<Binding>;
157
+ readChatMetadata: <Metadata = unknown>() => Promise<Metadata>;
158
+ renameChat: <Metadata = unknown>(title: string) => Promise<Metadata>;
159
+ rebindRuntime: <Input = unknown, Result = unknown>(input: Input) => Promise<Result>;
127
160
  updateConfig: <Change = unknown>(
128
161
  command: unknown,
129
162
  ) => Promise<RuntimeConfigUpdateResult<Change>>;
@@ -145,8 +178,15 @@ type SharedChatConnectionOptions = {
145
178
  host?: string;
146
179
  credentials?: RequestCredentials;
147
180
  protocols?: string | string[];
181
+ onMessage?: (message: MessageEvent) => void;
148
182
  inboxAgent?: string;
149
183
  sessionAgent?: string;
184
+ /** Skip history loading when the caller has just created this Chat. */
185
+ skipInitialMessages?: boolean;
186
+ /** Host projection used only until the selected Session sends its first state. */
187
+ initialTurnActive?: boolean;
188
+ /** Optional Agent RPC method injected by the Host to receive Turn timings. */
189
+ performanceRpc?: string;
150
190
  };
151
191
 
152
192
  export type ChatConnectionOptions = SharedChatConnectionOptions & (
@@ -154,9 +194,61 @@ export type ChatConnectionOptions = SharedChatConnectionOptions & (
154
194
  | { inboxName?: never; basePath?: string }
155
195
  );
156
196
 
197
+ type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
198
+ type UniversalAgentChatContextValue = {
199
+ chatId: string;
200
+ connection: ChatConnectionOptions;
201
+ agent: SessionAgentConnection;
202
+ chatAgent: SessionAgentConnection;
203
+ };
204
+
205
+ const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
206
+
207
+ export function UniversalAgentChatProvider({
208
+ chatId,
209
+ connection = {},
210
+ fallback = null,
211
+ children,
212
+ }: {
213
+ chatId: string;
214
+ connection?: ChatConnectionOptions;
215
+ fallback?: ReactNode;
216
+ children: ReactNode;
217
+ }) {
218
+ const inboxAgent = connection.inboxAgent ?? "Inbox";
219
+ const agent = useAgent<RuntimeState>({
220
+ agent: inboxAgent,
221
+ ...(connection.inboxName === undefined
222
+ ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
223
+ : { name: connection.inboxName }),
224
+ ...(connection.host === undefined ? {} : { host: connection.host }),
225
+ ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
226
+ ...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
227
+ connectionTimeout: 6_000,
228
+ sub: [{
229
+ agent: connection.sessionAgent ?? "UniversalAgent",
230
+ name: chatId,
231
+ }],
232
+ });
233
+ const chatAgent = useMemo(
234
+ () => createSafeUIMessageAgentConnection(agent),
235
+ [agent],
236
+ );
237
+ const value = useMemo(
238
+ () => ({ chatId, connection, agent, chatAgent }),
239
+ [agent, chatAgent, chatId, connection],
240
+ );
241
+ return createElement(
242
+ UniversalAgentChatContext.Provider,
243
+ { value },
244
+ createElement(Suspense, { fallback }, children),
245
+ );
246
+ }
247
+
157
248
  type ChatTurnTiming = {
158
249
  chatId: string;
159
250
  messageId: string;
251
+ submissionId?: string;
160
252
  baselineAssistantIds: Set<string>;
161
253
  click: number;
162
254
  rpcReceipt?: number;
@@ -165,6 +257,37 @@ type ChatTurnTiming = {
165
257
  ready?: number;
166
258
  };
167
259
 
260
+ type ClientPerformanceMetrics = {
261
+ clickToRpcMs: number;
262
+ rpcToStreamMs: number;
263
+ streamToFirstTextMs?: number;
264
+ firstTextToReadyMs?: number;
265
+ ttftMs?: number;
266
+ totalMs: number;
267
+ };
268
+
269
+ export function clientPerformanceMetrics(
270
+ timing: ChatTurnTiming,
271
+ ): ClientPerformanceMetrics | null {
272
+ if (
273
+ timing.rpcReceipt === undefined ||
274
+ timing.streamStart === undefined ||
275
+ timing.ready === undefined
276
+ ) return null;
277
+ return {
278
+ clickToRpcMs: timing.rpcReceipt - timing.click,
279
+ rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
280
+ ...(timing.firstText === undefined
281
+ ? {}
282
+ : {
283
+ streamToFirstTextMs: timing.firstText - timing.streamStart,
284
+ firstTextToReadyMs: timing.ready - timing.firstText,
285
+ ttftMs: timing.firstText - timing.click,
286
+ }),
287
+ totalMs: timing.ready - timing.click,
288
+ };
289
+ }
290
+
168
291
  const CHAT_TURN_SEGMENTS = {
169
292
  rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
170
293
  streamStart: [
@@ -260,10 +383,12 @@ function queuedPreview(message: ChatMessage): string {
260
383
  function loadInitialMessages(
261
384
  url: string | undefined,
262
385
  credentials: RequestCredentials | undefined,
386
+ view: "hydration" | "full" = "hydration",
263
387
  ): Promise<ChatMessage[]> {
264
388
  if (!url) return Promise.resolve([]);
265
389
  const messagesUrl = new URL(url);
266
390
  messagesUrl.pathname = `${messagesUrl.pathname.replace(/\/$/, "")}/get-messages`;
391
+ if (view === "hydration") messagesUrl.searchParams.set("view", "hydration");
267
392
  return getAgentMessages({
268
393
  url: messagesUrl.toString(),
269
394
  ...(credentials === undefined ? {} : { credentials }),
@@ -281,10 +406,12 @@ function loadInitialMessages(
281
406
  * sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
282
407
  * 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
283
408
  */
284
- export function useUniversalAgentChat(
285
- chatId: string,
286
- connection: ChatConnectionOptions = {},
287
- ): ChatRuntime {
409
+ export function useUniversalAgentChat(): ChatRuntime {
410
+ const context = useContext(UniversalAgentChatContext);
411
+ if (!context) {
412
+ throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
413
+ }
414
+ const { chatId, connection, agent, chatAgent } = context;
288
415
  const [optimisticMessages, setOptimisticMessages] =
289
416
  useState<ChatMessage[]>([]);
290
417
  const [optimisticQueued, setOptimisticQueued] =
@@ -292,25 +419,7 @@ export function useUniversalAgentChat(
292
419
  const [dispatchError, setDispatchError] = useState<string>();
293
420
  const [canRetry, setCanRetry] = useState(false);
294
421
  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
-
422
+ const stopRevisionRef = useRef(0);
314
423
  // 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
315
424
  // AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
316
425
  // 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
@@ -326,22 +435,27 @@ export function useUniversalAgentChat(
326
435
  isStreaming,
327
436
  error,
328
437
  clearError,
438
+ setMessages,
439
+ stop: stopChat,
329
440
  // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
330
441
  // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
331
442
  isServerStreaming,
332
- isRecovering,
443
+ isRecovering: sdkIsRecovering,
333
444
  isToolContinuation,
334
- connectionError,
445
+ connectionError: sdkConnectionError,
335
446
  } = useAgentChat<RuntimeState, ChatMessage>({
336
447
  agent: chatAgent,
337
- getInitialMessages: ({ url }) =>
338
- loadInitialMessages(url, connection.credentials),
448
+ getInitialMessages: connection.skipInitialMessages
449
+ ? null
450
+ : ({ url }) => loadInitialMessages(url, connection.credentials),
339
451
  ...(connection.credentials === undefined
340
452
  ? {}
341
453
  : { credentials: connection.credentials }),
342
454
  syncMessagesToServer: false,
343
- throttle: 100,
344
455
  });
456
+ // The SDK keeps its advisory recovery flag until terminal settlement; a
457
+ // live server stream proves the recovered Turn is executing again.
458
+ const isRecovering = sdkIsRecovering && !isServerStreaming;
345
459
  const visibleMessages = useMemo(
346
460
  () => mergeOptimisticMessages(messages, optimisticMessages),
347
461
  [messages, optimisticMessages],
@@ -361,10 +475,23 @@ export function useUniversalAgentChat(
361
475
  ? "ready"
362
476
  : sdkStatus;
363
477
  const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
478
+ const connectionError = sdkConnectionError ?? undefined;
479
+ const connectionStatus: ChatConnectionStatus = connectionError
480
+ ? connectionError.code === 1008 ? "rejected" : "error"
481
+ : agent.identified ? "connected" : "connecting";
482
+ const runtimeError = dispatchError ?? sdkError ?? connectionError;
483
+ const runtimeErrorMessage = runtimeError instanceof Error
484
+ ? runtimeError.message
485
+ : runtimeError;
486
+ const presentationError = isServerStreaming || isRecovering ||
487
+ (latestTurnStatus === "error" && latestMessage?.metadata?.error === runtimeErrorMessage)
488
+ ? undefined
489
+ : runtimeError;
364
490
  // RPC admissions bypass useChat's request lifecycle. Project the durable
365
491
  // Turn here so presentation stays correct without a second send path.
366
- const authoritativeStatus =
367
- normalizedSdkStatus === "ready" &&
492
+ const authoritativeStatus = isServerStreaming
493
+ ? "streaming"
494
+ : normalizedSdkStatus === "ready" &&
368
495
  !isRecovering &&
369
496
  approvals?.length === 0 &&
370
497
  authoritativeTurn?.activeSubmissionId
@@ -372,13 +499,45 @@ export function useUniversalAgentChat(
372
499
  ? "streaming"
373
500
  : "submitted"
374
501
  : normalizedSdkStatus;
502
+ const hydratingTurnActive = facetState === undefined &&
503
+ connection.initialTurnActive === true;
375
504
  const status = dispatchError
376
505
  ? "error"
377
- : optimisticMessages.length > 0 && authoritativeStatus === "ready"
506
+ : (optimisticMessages.length > 0 || hydratingTurnActive) &&
507
+ authoritativeStatus === "ready"
378
508
  ? "submitted"
379
509
  : authoritativeStatus;
380
510
  const messagesRef = useRef(messages);
381
511
  messagesRef.current = messages;
512
+ const replayFallbackMessageIdRef = useRef<string | undefined>(undefined);
513
+ useEffect(() => {
514
+ const activeMessageId = facetState?.turn?.activeMessageId;
515
+ if (
516
+ !activeMessageId ||
517
+ sdkStatus !== "error" ||
518
+ sdkIsRecovering ||
519
+ isServerStreaming ||
520
+ replayFallbackMessageIdRef.current === activeMessageId
521
+ ) return;
522
+ const url = chatAgent.getHttpUrl();
523
+ if (!url) return;
524
+ replayFallbackMessageIdRef.current = activeMessageId;
525
+ void loadInitialMessages(url, connection.credentials, "full")
526
+ .then((snapshot) => {
527
+ setMessages(snapshot);
528
+ clearError();
529
+ })
530
+ .catch(() => undefined);
531
+ }, [
532
+ chatAgent,
533
+ clearError,
534
+ connection.credentials,
535
+ facetState?.turn?.activeMessageId,
536
+ isServerStreaming,
537
+ sdkIsRecovering,
538
+ sdkStatus,
539
+ setMessages,
540
+ ]);
382
541
  const turnTimingRef = useRef<ChatTurnTiming | null>(null);
383
542
 
384
543
  useEffect(() => {
@@ -386,7 +545,8 @@ export function useUniversalAgentChat(
386
545
  if (!timing) return;
387
546
  const assistant = messages.find((message) =>
388
547
  message.role === "assistant" &&
389
- !timing.baselineAssistantIds.has(message.id)
548
+ !timing.baselineAssistantIds.has(message.id) &&
549
+ (!timing.submissionId || message.metadata?.turnId === timing.submissionId)
390
550
  );
391
551
  if (assistant) markChatTurnPhase(timing, "streamStart");
392
552
  if (
@@ -398,9 +558,18 @@ export function useUniversalAgentChat(
398
558
  }
399
559
  if (status === "ready" && timing.streamStart !== undefined) {
400
560
  if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
561
+ else timing.ready ??= now();
401
562
  turnTimingRef.current = null;
563
+ const metrics = clientPerformanceMetrics(timing);
564
+ if (connection.performanceRpc && timing.submissionId && metrics) {
565
+ void agentRef.current.call(connection.performanceRpc, [{
566
+ submissionId: timing.submissionId,
567
+ messageId: timing.messageId,
568
+ ...metrics,
569
+ }]).catch(() => {});
570
+ }
402
571
  }
403
- }, [messages, status]);
572
+ }, [connection.performanceRpc, messages, status]);
404
573
 
405
574
  useEffect(() => {
406
575
  if (optimisticMessages.length === 0 || messages.length === 0) return;
@@ -466,7 +635,8 @@ export function useUniversalAgentChat(
466
635
  isToolContinuation;
467
636
  const pendingLocalTurn = optimisticMessages.length > 0 &&
468
637
  (status === "submitted" || status === "streaming");
469
- const turnActive = Boolean(turn?.activeSubmissionId) ||
638
+ const turnActive = hydratingTurnActive ||
639
+ Boolean(turn?.activeSubmissionId) ||
470
640
  pendingLocalTurn ||
471
641
  (authoritativeTurn === undefined && sdkTurnActive);
472
642
  const canSteer = turnActive &&
@@ -485,17 +655,32 @@ export function useUniversalAgentChat(
485
655
  const dispatchMessage = useCallback(async (
486
656
  message: ChatMessage,
487
657
  delivery: MessageDelivery,
488
- ): Promise<MessageDispatchReceipt> =>
489
- agentRef.current.call<MessageDispatchReceipt>(
658
+ ): Promise<MessageDispatchReceipt> => {
659
+ const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
490
660
  "dispatchMessage",
491
661
  [message, delivery],
492
- ), []);
662
+ );
663
+ try {
664
+ return await dispatch();
665
+ } catch (error) {
666
+ // The server deduplicates dispatches by message.id, so replay only the
667
+ // transport failures that mean admission may already have succeeded.
668
+ const retryable = error instanceof Error && (
669
+ /^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
670
+ (error.message === "Connection closed" && agentRef.current.shouldReconnect)
671
+ );
672
+ if (!retryable) throw error;
673
+ await agentRef.current.ready;
674
+ return dispatch();
675
+ }
676
+ }, []);
493
677
 
494
678
  const submitMessage = useCallback(async (
495
679
  message: ChatMessage,
496
680
  delivery: MessageDelivery,
497
681
  projection: "message" | "queue",
498
682
  ): Promise<boolean> => {
683
+ const stopRevision = stopRevisionRef.current;
499
684
  setDispatchError(undefined);
500
685
  clearError();
501
686
  failedDispatchRef.current = undefined;
@@ -513,7 +698,6 @@ export function useUniversalAgentChat(
513
698
  };
514
699
  }
515
700
  if (projection === "message") {
516
- activeSubmissionIdRef.current = undefined;
517
701
  setOptimisticMessages((current) =>
518
702
  current.some(({ id }) => id === message.id)
519
703
  ? current
@@ -558,9 +742,20 @@ export function useUniversalAgentChat(
558
742
  return false;
559
743
  }
560
744
  if (projection === "message") {
561
- activeSubmissionIdRef.current = receipt.kind === "queued"
745
+ const submissionId = receipt.kind === "queued"
562
746
  ? receipt.submission.submissionId
563
747
  : receipt.submissionId;
748
+ if (stopRevision !== stopRevisionRef.current) {
749
+ await agentRef.current.call<{ ok: boolean }>(
750
+ "cancelSubmissionById",
751
+ [submissionId, USER_STOP_REASON],
752
+ );
753
+ rollback();
754
+ return true;
755
+ }
756
+ if (turnTimingRef.current?.messageId === message.id) {
757
+ turnTimingRef.current.submissionId = submissionId;
758
+ }
564
759
  }
565
760
  if (projection === "queue") {
566
761
  if (receipt.kind !== "queued" || receipt.position < 1) {
@@ -678,29 +873,49 @@ export function useUniversalAgentChat(
678
873
  }, []);
679
874
 
680
875
  const stop = useCallback(async () => {
876
+ stopRevisionRef.current += 1;
681
877
  const requestId = authoritativeTurn?.activeRequestId;
682
- const submissionId = authoritativeTurn?.activeSubmissionId ??
683
- activeSubmissionIdRef.current;
878
+ const submissionId = authoritativeTurn?.activeSubmissionId;
879
+ const beforeStop = messagesRef.current;
880
+ const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
881
+ const assistantIndex = beforeStop.findLastIndex(
882
+ ({ role }, index) => index > userIndex && role === "assistant",
883
+ );
884
+ const marker = {
885
+ completedAt: Date.now(),
886
+ interruptedByUser: true,
887
+ turnStatus: "aborted",
888
+ } as const;
889
+ setMessages(assistantIndex < 0
890
+ ? [...beforeStop, {
891
+ id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
892
+ role: "assistant",
893
+ metadata: marker,
894
+ parts: [],
895
+ }]
896
+ : beforeStop.map((message, index) =>
897
+ index === assistantIndex
898
+ ? { ...message, metadata: { ...message.metadata, ...marker } }
899
+ : message
900
+ ));
901
+ const rollback = () => setMessages(beforeStop);
684
902
  setDispatchError(undefined);
685
903
  try {
904
+ await agentRef.current.call<{ ok: boolean }>(
905
+ "stopAllSubmissions",
906
+ [USER_STOP_REASON],
907
+ );
908
+ setOptimisticMessages([]);
909
+ setOptimisticQueued([]);
686
910
  if (requestId) {
687
911
  agentRef.current.send(JSON.stringify({
688
912
  type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
689
913
  id: requestId,
690
914
  }));
691
- return;
915
+ await stopChat();
692
916
  }
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
917
  } catch (cause) {
918
+ rollback();
704
919
  setDispatchError(
705
920
  cause instanceof Error ? cause.message : String(cause),
706
921
  );
@@ -708,6 +923,7 @@ export function useUniversalAgentChat(
708
923
  }, [
709
924
  authoritativeTurn?.activeRequestId,
710
925
  authoritativeTurn?.activeSubmissionId,
926
+ stopChat,
711
927
  ]);
712
928
 
713
929
  const retry = useCallback(() => {
@@ -736,6 +952,14 @@ export function useUniversalAgentChat(
736
952
  return false;
737
953
  }
738
954
  }, []);
955
+ const decideApproval = useCallback(
956
+ (executionId: string, decision: ApprovalDecision) =>
957
+ agentRef.current.call<{ ok: boolean }>(
958
+ "decideApproval",
959
+ [executionId, decision],
960
+ ),
961
+ [],
962
+ );
739
963
 
740
964
  // 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
741
965
  // 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
@@ -743,6 +967,24 @@ export function useUniversalAgentChat(
743
967
  () => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
744
968
  [],
745
969
  );
970
+ const getRuntimeBinding = useCallback(
971
+ <Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
972
+ [],
973
+ );
974
+ const readChatMetadata = useCallback(
975
+ <Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
976
+ [],
977
+ );
978
+ const renameChat = useCallback(
979
+ <Metadata,>(title: string) =>
980
+ agentRef.current.call<Metadata>("renameChat", [title]),
981
+ [],
982
+ );
983
+ const rebindRuntime = useCallback(
984
+ <Input, Result,>(input: Input) =>
985
+ agentRef.current.call<Result>("rebindRuntime", [input]),
986
+ [],
987
+ );
746
988
  const updateConfig = useCallback(
747
989
  <Change,>(command: unknown) =>
748
990
  agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
@@ -761,15 +1003,21 @@ export function useUniversalAgentChat(
761
1003
 
762
1004
  return {
763
1005
  respondToolInteraction,
1006
+ decideApproval,
764
1007
  getRuntimeAssembly,
1008
+ getRuntimeBinding,
1009
+ readChatMetadata,
1010
+ renameChat,
1011
+ rebindRuntime,
765
1012
  updateConfig,
766
1013
  reloadRuntime,
767
1014
  messages: visibleMessages,
768
1015
  status,
1016
+ connectionStatus,
1017
+ connectionError,
769
1018
  runtimeLoad: facetState?.runtimeLoad,
770
1019
  isStreaming,
771
- error: dispatchError ?? sdkError ??
772
- connectionError ?? undefined,
1020
+ error: presentationError,
773
1021
  sendText,
774
1022
  steerText,
775
1023
  enqueueText,