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

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.40",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -13,7 +13,7 @@
13
13
  ".": "./src/index.ts"
14
14
  },
15
15
  "peerDependencies": {
16
- "agents": "^0.19.0",
16
+ "agents": "^0.22.0",
17
17
  "ai": "^7.0.0",
18
18
  "react": "^19.0.0"
19
19
  },
@@ -25,7 +25,7 @@
25
25
  "@types/react-dom": "^19.2.3",
26
26
  "react-dom": "^19.2.7",
27
27
  "typescript": "^7.0.2",
28
- "@springbrand/agent-runtime": "0.1.3-alpha.5"
28
+ "@springbrand/agent-runtime": "0.2.0-alpha.51"
29
29
  },
30
30
  "scripts": {
31
31
  "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
18
  import { getAgentMessages, useAgentChat } from "agents/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;
@@ -84,6 +106,8 @@ type ChatTurn = {
84
106
  export interface ChatRuntime {
85
107
  messages: ChatMessage[];
86
108
  status: ChatStatus;
109
+ connectionStatus: ChatConnectionStatus;
110
+ connectionError: AgentConnectionError | undefined;
87
111
  runtimeLoad: ChatRuntimeLoadState | undefined;
88
112
  isStreaming: boolean;
89
113
  error: string | Error | undefined;
@@ -115,6 +139,10 @@ export interface ChatRuntime {
115
139
  toolCallId: string,
116
140
  response: unknown,
117
141
  ) => Promise<boolean>;
142
+ decideApproval: (
143
+ executionId: string,
144
+ decision: ApprovalDecision,
145
+ ) => Promise<{ ok: boolean }>;
118
146
  /**
119
147
  * 读取该 Session facet 当前真正装配出来的 Runtime。
120
148
  *
@@ -124,6 +152,10 @@ export interface ChatRuntime {
124
152
  * 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
125
153
  */
126
154
  getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
155
+ getRuntimeBinding: <Binding = unknown>() => Promise<Binding>;
156
+ readChatMetadata: <Metadata = unknown>() => Promise<Metadata>;
157
+ renameChat: <Metadata = unknown>(title: string) => Promise<Metadata>;
158
+ rebindRuntime: <Input = unknown, Result = unknown>(input: Input) => Promise<Result>;
127
159
  updateConfig: <Change = unknown>(
128
160
  command: unknown,
129
161
  ) => Promise<RuntimeConfigUpdateResult<Change>>;
@@ -145,8 +177,15 @@ type SharedChatConnectionOptions = {
145
177
  host?: string;
146
178
  credentials?: RequestCredentials;
147
179
  protocols?: string | string[];
180
+ onMessage?: (message: MessageEvent) => void;
148
181
  inboxAgent?: string;
149
182
  sessionAgent?: string;
183
+ /** Skip history loading when the caller has just created this Chat. */
184
+ skipInitialMessages?: boolean;
185
+ /** Host projection used only until the selected Session sends its first state. */
186
+ initialTurnActive?: boolean;
187
+ /** Optional Agent RPC method injected by the Host to receive Turn timings. */
188
+ performanceRpc?: string;
150
189
  };
151
190
 
152
191
  export type ChatConnectionOptions = SharedChatConnectionOptions & (
@@ -154,9 +193,61 @@ export type ChatConnectionOptions = SharedChatConnectionOptions & (
154
193
  | { inboxName?: never; basePath?: string }
155
194
  );
156
195
 
196
+ type SessionAgentConnection = ReturnType<typeof useAgent<RuntimeState>>;
197
+ type UniversalAgentChatContextValue = {
198
+ chatId: string;
199
+ connection: ChatConnectionOptions;
200
+ agent: SessionAgentConnection;
201
+ chatAgent: SessionAgentConnection;
202
+ };
203
+
204
+ const UniversalAgentChatContext = createContext<UniversalAgentChatContextValue | null>(null);
205
+
206
+ export function UniversalAgentChatProvider({
207
+ chatId,
208
+ connection = {},
209
+ fallback = null,
210
+ children,
211
+ }: {
212
+ chatId: string;
213
+ connection?: ChatConnectionOptions;
214
+ fallback?: ReactNode;
215
+ children: ReactNode;
216
+ }) {
217
+ const inboxAgent = connection.inboxAgent ?? "Inbox";
218
+ const agent = useAgent<RuntimeState>({
219
+ agent: inboxAgent,
220
+ ...(connection.inboxName === undefined
221
+ ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
222
+ : { name: connection.inboxName }),
223
+ ...(connection.host === undefined ? {} : { host: connection.host }),
224
+ ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
225
+ ...(connection.onMessage === undefined ? {} : { onMessage: connection.onMessage }),
226
+ connectionTimeout: 6_000,
227
+ sub: [{
228
+ agent: connection.sessionAgent ?? "UniversalAgent",
229
+ name: chatId,
230
+ }],
231
+ });
232
+ const chatAgent = useMemo(
233
+ () => createSafeUIMessageAgentConnection(agent),
234
+ [agent],
235
+ );
236
+ const value = useMemo(
237
+ () => ({ chatId, connection, agent, chatAgent }),
238
+ [agent, chatAgent, chatId, connection],
239
+ );
240
+ return createElement(
241
+ UniversalAgentChatContext.Provider,
242
+ { value },
243
+ createElement(Suspense, { fallback }, children),
244
+ );
245
+ }
246
+
157
247
  type ChatTurnTiming = {
158
248
  chatId: string;
159
249
  messageId: string;
250
+ submissionId?: string;
160
251
  baselineAssistantIds: Set<string>;
161
252
  click: number;
162
253
  rpcReceipt?: number;
@@ -165,6 +256,37 @@ type ChatTurnTiming = {
165
256
  ready?: number;
166
257
  };
167
258
 
259
+ type ClientPerformanceMetrics = {
260
+ clickToRpcMs: number;
261
+ rpcToStreamMs: number;
262
+ streamToFirstTextMs?: number;
263
+ firstTextToReadyMs?: number;
264
+ ttftMs?: number;
265
+ totalMs: number;
266
+ };
267
+
268
+ export function clientPerformanceMetrics(
269
+ timing: ChatTurnTiming,
270
+ ): ClientPerformanceMetrics | null {
271
+ if (
272
+ timing.rpcReceipt === undefined ||
273
+ timing.streamStart === undefined ||
274
+ timing.ready === undefined
275
+ ) return null;
276
+ return {
277
+ clickToRpcMs: timing.rpcReceipt - timing.click,
278
+ rpcToStreamMs: timing.streamStart - timing.rpcReceipt,
279
+ ...(timing.firstText === undefined
280
+ ? {}
281
+ : {
282
+ streamToFirstTextMs: timing.firstText - timing.streamStart,
283
+ firstTextToReadyMs: timing.ready - timing.firstText,
284
+ ttftMs: timing.firstText - timing.click,
285
+ }),
286
+ totalMs: timing.ready - timing.click,
287
+ };
288
+ }
289
+
168
290
  const CHAT_TURN_SEGMENTS = {
169
291
  rpcReceipt: ["click", "universal-agent.chat.click_to_rpc_receipt"],
170
292
  streamStart: [
@@ -281,10 +403,12 @@ function loadInitialMessages(
281
403
  * sub 数组由客户端 kebab 化;服务端按 ctx.exports 反解回 CamelCase className,
282
404
  * 与 Inbox.onBeforeSubAgent 的严格门卫(hasSubAgent)对齐。
283
405
  */
284
- export function useUniversalAgentChat(
285
- chatId: string,
286
- connection: ChatConnectionOptions = {},
287
- ): ChatRuntime {
406
+ export function useUniversalAgentChat(): ChatRuntime {
407
+ const context = useContext(UniversalAgentChatContext);
408
+ if (!context) {
409
+ throw new Error("useUniversalAgentChat must be used within UniversalAgentChatProvider");
410
+ }
411
+ const { chatId, connection, agent, chatAgent } = context;
288
412
  const [optimisticMessages, setOptimisticMessages] =
289
413
  useState<ChatMessage[]>([]);
290
414
  const [optimisticQueued, setOptimisticQueued] =
@@ -292,25 +416,7 @@ export function useUniversalAgentChat(
292
416
  const [dispatchError, setDispatchError] = useState<string>();
293
417
  const [canRetry, setCanRetry] = useState(false);
294
418
  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
-
419
+ const stopRevisionRef = useRef(0);
314
420
  // 轮询→推送:这条 chat 连接连的是 UniversalAgent facet,`agent.state` 即 facet 的
315
421
  // AgentState 广播(useAgent 内部 useState,收到 cf_agent_state 即 re-render)。待批项
316
422
  // 随它推来 —— 审批读侧复用这条已开的连接,不另开第二条 WebSocket。三态(原则 VII):
@@ -326,22 +432,28 @@ export function useUniversalAgentChat(
326
432
  isStreaming,
327
433
  error,
328
434
  clearError,
435
+ setMessages,
436
+ stop: stopChat,
329
437
  // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
330
438
  // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
331
439
  isServerStreaming,
332
- isRecovering,
440
+ isRecovering: sdkIsRecovering,
333
441
  isToolContinuation,
334
- connectionError,
442
+ connectionError: sdkConnectionError,
335
443
  } = useAgentChat<RuntimeState, ChatMessage>({
336
444
  agent: chatAgent,
337
- getInitialMessages: ({ url }) =>
338
- loadInitialMessages(url, connection.credentials),
445
+ getInitialMessages: connection.skipInitialMessages
446
+ ? null
447
+ : ({ url }) => loadInitialMessages(url, connection.credentials),
339
448
  ...(connection.credentials === undefined
340
449
  ? {}
341
450
  : { credentials: connection.credentials }),
342
451
  syncMessagesToServer: false,
343
- throttle: 100,
452
+ throttle: 16,
344
453
  });
454
+ // The SDK keeps its advisory recovery flag until terminal settlement; a
455
+ // live server stream proves the recovered Turn is executing again.
456
+ const isRecovering = sdkIsRecovering && !isServerStreaming;
345
457
  const visibleMessages = useMemo(
346
458
  () => mergeOptimisticMessages(messages, optimisticMessages),
347
459
  [messages, optimisticMessages],
@@ -361,6 +473,18 @@ export function useUniversalAgentChat(
361
473
  ? "ready"
362
474
  : sdkStatus;
363
475
  const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
476
+ const connectionError = sdkConnectionError ?? undefined;
477
+ const connectionStatus: ChatConnectionStatus = connectionError
478
+ ? connectionError.code === 1008 ? "rejected" : "error"
479
+ : agent.identified ? "connected" : "connecting";
480
+ const runtimeError = dispatchError ?? sdkError ?? connectionError;
481
+ const runtimeErrorMessage = runtimeError instanceof Error
482
+ ? runtimeError.message
483
+ : runtimeError;
484
+ const presentationError = latestTurnStatus === "error" &&
485
+ latestMessage?.metadata?.error === runtimeErrorMessage
486
+ ? undefined
487
+ : runtimeError;
364
488
  // RPC admissions bypass useChat's request lifecycle. Project the durable
365
489
  // Turn here so presentation stays correct without a second send path.
366
490
  const authoritativeStatus =
@@ -372,9 +496,12 @@ export function useUniversalAgentChat(
372
496
  ? "streaming"
373
497
  : "submitted"
374
498
  : normalizedSdkStatus;
499
+ const hydratingTurnActive = facetState === undefined &&
500
+ connection.initialTurnActive === true;
375
501
  const status = dispatchError
376
502
  ? "error"
377
- : optimisticMessages.length > 0 && authoritativeStatus === "ready"
503
+ : (optimisticMessages.length > 0 || hydratingTurnActive) &&
504
+ authoritativeStatus === "ready"
378
505
  ? "submitted"
379
506
  : authoritativeStatus;
380
507
  const messagesRef = useRef(messages);
@@ -386,7 +513,8 @@ export function useUniversalAgentChat(
386
513
  if (!timing) return;
387
514
  const assistant = messages.find((message) =>
388
515
  message.role === "assistant" &&
389
- !timing.baselineAssistantIds.has(message.id)
516
+ !timing.baselineAssistantIds.has(message.id) &&
517
+ (!timing.submissionId || message.metadata?.turnId === timing.submissionId)
390
518
  );
391
519
  if (assistant) markChatTurnPhase(timing, "streamStart");
392
520
  if (
@@ -398,9 +526,18 @@ export function useUniversalAgentChat(
398
526
  }
399
527
  if (status === "ready" && timing.streamStart !== undefined) {
400
528
  if (timing.firstText !== undefined) markChatTurnPhase(timing, "ready");
529
+ else timing.ready ??= now();
401
530
  turnTimingRef.current = null;
531
+ const metrics = clientPerformanceMetrics(timing);
532
+ if (connection.performanceRpc && timing.submissionId && metrics) {
533
+ void agentRef.current.call(connection.performanceRpc, [{
534
+ submissionId: timing.submissionId,
535
+ messageId: timing.messageId,
536
+ ...metrics,
537
+ }]).catch(() => {});
538
+ }
402
539
  }
403
- }, [messages, status]);
540
+ }, [connection.performanceRpc, messages, status]);
404
541
 
405
542
  useEffect(() => {
406
543
  if (optimisticMessages.length === 0 || messages.length === 0) return;
@@ -466,7 +603,8 @@ export function useUniversalAgentChat(
466
603
  isToolContinuation;
467
604
  const pendingLocalTurn = optimisticMessages.length > 0 &&
468
605
  (status === "submitted" || status === "streaming");
469
- const turnActive = Boolean(turn?.activeSubmissionId) ||
606
+ const turnActive = hydratingTurnActive ||
607
+ Boolean(turn?.activeSubmissionId) ||
470
608
  pendingLocalTurn ||
471
609
  (authoritativeTurn === undefined && sdkTurnActive);
472
610
  const canSteer = turnActive &&
@@ -485,17 +623,32 @@ export function useUniversalAgentChat(
485
623
  const dispatchMessage = useCallback(async (
486
624
  message: ChatMessage,
487
625
  delivery: MessageDelivery,
488
- ): Promise<MessageDispatchReceipt> =>
489
- agentRef.current.call<MessageDispatchReceipt>(
626
+ ): Promise<MessageDispatchReceipt> => {
627
+ const dispatch = () => agentRef.current.call<MessageDispatchReceipt>(
490
628
  "dispatchMessage",
491
629
  [message, delivery],
492
- ), []);
630
+ );
631
+ try {
632
+ return await dispatch();
633
+ } catch (error) {
634
+ // The server deduplicates dispatches by message.id, so replay only the
635
+ // transport failures that mean admission may already have succeeded.
636
+ const retryable = error instanceof Error && (
637
+ /^RPC call to dispatchMessage timed out after \d+ms$/u.test(error.message) ||
638
+ (error.message === "Connection closed" && agentRef.current.shouldReconnect)
639
+ );
640
+ if (!retryable) throw error;
641
+ await agentRef.current.ready;
642
+ return dispatch();
643
+ }
644
+ }, []);
493
645
 
494
646
  const submitMessage = useCallback(async (
495
647
  message: ChatMessage,
496
648
  delivery: MessageDelivery,
497
649
  projection: "message" | "queue",
498
650
  ): Promise<boolean> => {
651
+ const stopRevision = stopRevisionRef.current;
499
652
  setDispatchError(undefined);
500
653
  clearError();
501
654
  failedDispatchRef.current = undefined;
@@ -513,7 +666,6 @@ export function useUniversalAgentChat(
513
666
  };
514
667
  }
515
668
  if (projection === "message") {
516
- activeSubmissionIdRef.current = undefined;
517
669
  setOptimisticMessages((current) =>
518
670
  current.some(({ id }) => id === message.id)
519
671
  ? current
@@ -558,9 +710,20 @@ export function useUniversalAgentChat(
558
710
  return false;
559
711
  }
560
712
  if (projection === "message") {
561
- activeSubmissionIdRef.current = receipt.kind === "queued"
713
+ const submissionId = receipt.kind === "queued"
562
714
  ? receipt.submission.submissionId
563
715
  : receipt.submissionId;
716
+ if (stopRevision !== stopRevisionRef.current) {
717
+ await agentRef.current.call<{ ok: boolean }>(
718
+ "cancelSubmissionById",
719
+ [submissionId, USER_STOP_REASON],
720
+ );
721
+ rollback();
722
+ return true;
723
+ }
724
+ if (turnTimingRef.current?.messageId === message.id) {
725
+ turnTimingRef.current.submissionId = submissionId;
726
+ }
564
727
  }
565
728
  if (projection === "queue") {
566
729
  if (receipt.kind !== "queued" || receipt.position < 1) {
@@ -678,29 +841,49 @@ export function useUniversalAgentChat(
678
841
  }, []);
679
842
 
680
843
  const stop = useCallback(async () => {
844
+ stopRevisionRef.current += 1;
681
845
  const requestId = authoritativeTurn?.activeRequestId;
682
- const submissionId = authoritativeTurn?.activeSubmissionId ??
683
- activeSubmissionIdRef.current;
846
+ const submissionId = authoritativeTurn?.activeSubmissionId;
847
+ const beforeStop = messagesRef.current;
848
+ const userIndex = beforeStop.findLastIndex(({ role }) => role === "user");
849
+ const assistantIndex = beforeStop.findLastIndex(
850
+ ({ role }, index) => index > userIndex && role === "assistant",
851
+ );
852
+ const marker = {
853
+ completedAt: Date.now(),
854
+ interruptedByUser: true,
855
+ turnStatus: "aborted",
856
+ } as const;
857
+ setMessages(assistantIndex < 0
858
+ ? [...beforeStop, {
859
+ id: `optimistic-stop:${submissionId ?? requestId ?? crypto.randomUUID()}`,
860
+ role: "assistant",
861
+ metadata: marker,
862
+ parts: [],
863
+ }]
864
+ : beforeStop.map((message, index) =>
865
+ index === assistantIndex
866
+ ? { ...message, metadata: { ...message.metadata, ...marker } }
867
+ : message
868
+ ));
869
+ const rollback = () => setMessages(beforeStop);
684
870
  setDispatchError(undefined);
685
871
  try {
872
+ await agentRef.current.call<{ ok: boolean }>(
873
+ "stopAllSubmissions",
874
+ [USER_STOP_REASON],
875
+ );
876
+ setOptimisticMessages([]);
877
+ setOptimisticQueued([]);
686
878
  if (requestId) {
687
879
  agentRef.current.send(JSON.stringify({
688
880
  type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
689
881
  id: requestId,
690
882
  }));
691
- return;
883
+ await stopChat();
692
884
  }
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
885
  } catch (cause) {
886
+ rollback();
704
887
  setDispatchError(
705
888
  cause instanceof Error ? cause.message : String(cause),
706
889
  );
@@ -708,6 +891,7 @@ export function useUniversalAgentChat(
708
891
  }, [
709
892
  authoritativeTurn?.activeRequestId,
710
893
  authoritativeTurn?.activeSubmissionId,
894
+ stopChat,
711
895
  ]);
712
896
 
713
897
  const retry = useCallback(() => {
@@ -736,6 +920,14 @@ export function useUniversalAgentChat(
736
920
  return false;
737
921
  }
738
922
  }, []);
923
+ const decideApproval = useCallback(
924
+ (executionId: string, decision: ApprovalDecision) =>
925
+ agentRef.current.call<{ ok: boolean }>(
926
+ "decideApproval",
927
+ [executionId, decision],
928
+ ),
929
+ [],
930
+ );
739
931
 
740
932
  // 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
741
933
  // 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
@@ -743,6 +935,24 @@ export function useUniversalAgentChat(
743
935
  () => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
744
936
  [],
745
937
  );
938
+ const getRuntimeBinding = useCallback(
939
+ <Binding,>() => agentRef.current.call<Binding>("getRuntimeBinding", []),
940
+ [],
941
+ );
942
+ const readChatMetadata = useCallback(
943
+ <Metadata,>() => agentRef.current.call<Metadata>("readChatMetadata", []),
944
+ [],
945
+ );
946
+ const renameChat = useCallback(
947
+ <Metadata,>(title: string) =>
948
+ agentRef.current.call<Metadata>("renameChat", [title]),
949
+ [],
950
+ );
951
+ const rebindRuntime = useCallback(
952
+ <Input, Result,>(input: Input) =>
953
+ agentRef.current.call<Result>("rebindRuntime", [input]),
954
+ [],
955
+ );
746
956
  const updateConfig = useCallback(
747
957
  <Change,>(command: unknown) =>
748
958
  agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
@@ -761,15 +971,21 @@ export function useUniversalAgentChat(
761
971
 
762
972
  return {
763
973
  respondToolInteraction,
974
+ decideApproval,
764
975
  getRuntimeAssembly,
976
+ getRuntimeBinding,
977
+ readChatMetadata,
978
+ renameChat,
979
+ rebindRuntime,
765
980
  updateConfig,
766
981
  reloadRuntime,
767
982
  messages: visibleMessages,
768
983
  status,
984
+ connectionStatus,
985
+ connectionError,
769
986
  runtimeLoad: facetState?.runtimeLoad,
770
987
  isStreaming,
771
- error: dispatchError ?? sdkError ??
772
- connectionError ?? undefined,
988
+ error: presentationError,
773
989
  sendText,
774
990
  steerText,
775
991
  enqueueText,