@springbrand/chat-client 0.1.3-alpha.0 → 0.1.3-alpha.10

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.0",
3
+ "version": "0.1.3-alpha.10",
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.20.1",
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.1"
28
+ "@springbrand/agent-runtime": "0.2.0-alpha.15"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit"
@@ -1,35 +1,22 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useAgent } from "agents/react";
3
3
 
4
- export interface ChatSessionSummary {
5
- id: string;
6
- userAgentId: string;
7
- title: string;
8
- titleSource: "default" | "generated" | "manual";
9
- createdAt: number;
10
- updatedAt: number;
11
- pinned: boolean;
12
- archived: boolean;
13
- activity: "idle" | "working" | "needs-input";
14
- usage?: {
15
- totalTokens: number;
16
- totalCost: number;
17
- };
18
- }
19
-
20
- interface InboxState {
21
- chats: ChatSessionSummary[];
4
+ interface InboxState<Session> {
5
+ chats: Session[];
22
6
  workspaceRev?: number;
23
7
  }
24
8
 
25
9
  export const CURRENT_INBOX_AGENT_PATH = "api/agent-connections/inbox";
26
10
 
27
11
  type Request = typeof fetch;
12
+ export type ChatSessionsOptions = {
13
+ protocols?: string | string[];
14
+ };
28
15
 
29
- export async function loadChatSessions(
16
+ export async function loadChatSessions<Session>(
30
17
  signal?: AbortSignal,
31
18
  request: Request = fetch,
32
- ): Promise<ChatSessionSummary[]> {
19
+ ): Promise<Session[]> {
33
20
  const response = await request("/api/chats", {
34
21
  cache: "no-store",
35
22
  headers: { Accept: "application/json" },
@@ -42,21 +29,22 @@ export async function loadChatSessions(
42
29
  if (!Array.isArray(body?.chats)) {
43
30
  throw new Error("Invalid chat sessions response");
44
31
  }
45
- return body.chats as ChatSessionSummary[];
32
+ return body.chats as Session[];
46
33
  }
47
34
 
48
35
  /** Browser client for the user Inbox. Routing and presentation stay with the host UI. */
49
- export function useChatSessions() {
50
- const inbox = useAgent<InboxState>({
36
+ export function useChatSessions<Session>(options: ChatSessionsOptions = {}) {
37
+ const inbox = useAgent<InboxState<Session>>({
51
38
  agent: "Inbox",
52
39
  basePath: CURRENT_INBOX_AGENT_PATH,
40
+ ...(options.protocols === undefined ? {} : { protocols: options.protocols }),
53
41
  });
54
- const [snapshot, setSnapshot] = useState<ChatSessionSummary[]>();
42
+ const [snapshot, setSnapshot] = useState<Session[]>();
55
43
  const creatingRef = useRef(false);
56
44
 
57
45
  useEffect(() => {
58
46
  const controller = new AbortController();
59
- void loadChatSessions(controller.signal)
47
+ void loadChatSessions<Session>(controller.signal)
60
48
  .then(setSnapshot)
61
49
  .catch(() => undefined); // WebSocket state remains the fallback.
62
50
  return () => controller.abort();
@@ -67,7 +55,7 @@ export function useChatSessions() {
67
55
  creatingRef.current = true;
68
56
  try {
69
57
  await inbox.ready;
70
- return await inbox.call<ChatSessionSummary>(
58
+ return await inbox.call<Session>(
71
59
  "createChat",
72
60
  userAgentId ? [{ userAgentId }] : undefined,
73
61
  );
@@ -78,7 +66,7 @@ export function useChatSessions() {
78
66
 
79
67
  const forkChat = useCallback(
80
68
  (sourceChatId: string, messageId: string) =>
81
- inbox.call<ChatSessionSummary>("forkChat", [sourceChatId, messageId]),
69
+ inbox.call<Session>("forkChat", [sourceChatId, messageId]),
82
70
  [inbox],
83
71
  );
84
72
 
@@ -100,18 +88,6 @@ export function useChatSessions() {
100
88
  await inbox.call("archiveChat", [id, archived]);
101
89
  }, [inbox]);
102
90
 
103
- const reloadChatRuntime = useCallback(
104
- async (chatId: string, userAgentId: string) => {
105
- const result = await inbox.call<{ userAgentId: string } | null>(
106
- "chatReloadRuntime",
107
- [chatId, userAgentId],
108
- );
109
- if (!result) throw new Error("Session is unavailable");
110
- return result;
111
- },
112
- [inbox],
113
- );
114
-
115
91
  return {
116
92
  chats: inbox.state?.chats ?? snapshot ?? [],
117
93
  synced: inbox.state !== undefined || snapshot !== undefined,
@@ -123,8 +99,9 @@ export function useChatSessions() {
123
99
  deleteChat,
124
100
  pinChat,
125
101
  archiveChat,
126
- reloadChatRuntime,
127
102
  };
128
103
  }
129
104
 
130
- export type ChatSessions = ReturnType<typeof useChatSessions>;
105
+ export type ChatSessions<Session> = ReturnType<
106
+ typeof useChatSessions<Session>
107
+ >;
package/src/index.ts CHANGED
@@ -19,6 +19,6 @@ export {
19
19
  } from "./chat-summary";
20
20
  export {
21
21
  useChatSessions,
22
- type ChatSessionSummary,
22
+ type ChatSessionsOptions,
23
23
  type ChatSessions,
24
24
  } from "./chat-sessions";
@@ -2,11 +2,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
2
  import { nanoid } from "nanoid";
3
3
  import type { ChatStatus, FileUIPart, UIMessage } from "ai";
4
4
  import type { AgentToolRunState } from "agents";
5
+ import { MessageType } from "agents/chat";
5
6
  import { getAgentMessages, useAgentChat } from "agents/chat/react";
6
7
  import { useAgent, useAgentToolEvents } from "agents/react";
7
8
  import {
8
9
  type MessageDelivery,
9
10
  type MessageDispatchReceipt,
11
+ type RequestedCapability,
12
+ type RuntimeAssemblyView,
13
+ type RuntimeConfigUpdateResult,
10
14
  type RuntimeQueuedSubmission,
11
15
  type RuntimeState,
12
16
  } from "@springbrand/agent-runtime/contracts";
@@ -14,6 +18,7 @@ import { createSafeUIMessageAgentConnection } from "./ui-message-stream-guard";
14
18
  import { CURRENT_INBOX_AGENT_PATH } from "./chat-sessions";
15
19
 
16
20
  type ChatMessageMetadata = Record<string, unknown> & {
21
+ requestedCapabilities?: readonly RequestedCapability[];
17
22
  turnStatus?: string;
18
23
  };
19
24
  type ChatMessage = UIMessage<ChatMessageMetadata>;
@@ -27,7 +32,7 @@ type ChatRuntimeLoadState =
27
32
  | { status: "idle"; available: false }
28
33
  | {
29
34
  status: "loading";
30
- phase: "config" | "plugins" | "mcp" | "pi";
35
+ phase: "config" | "assembly" | "mcp" | "pi";
31
36
  available: boolean;
32
37
  startedAt: number;
33
38
  updatedAt: number;
@@ -40,7 +45,7 @@ type ChatRuntimeLoadState =
40
45
  }
41
46
  | {
42
47
  status: "error";
43
- phase: "config" | "plugins" | "mcp" | "pi";
48
+ phase: "config" | "assembly" | "mcp" | "pi";
44
49
  available: boolean;
45
50
  startedAt: number;
46
51
  failedAt: number;
@@ -67,6 +72,10 @@ type ChatQueuedSubmission = {
67
72
 
68
73
  type ChatTurn = {
69
74
  activeSubmissionId?: string;
75
+ activeRequestId?: string;
76
+ recoveryAttempt?: number;
77
+ recoveryMax?: number;
78
+ recoveryReason?: "no_meaningful_model_progress" | "transient_model_error";
70
79
  steerable: boolean;
71
80
  hasPendingSteer: boolean;
72
81
  queued: ChatQueuedSubmission[];
@@ -78,12 +87,47 @@ export interface ChatRuntime {
78
87
  runtimeLoad: ChatRuntimeLoadState | undefined;
79
88
  isStreaming: boolean;
80
89
  error: string | Error | undefined;
81
- sendText: (text: string, files?: readonly FileUIPart[]) => Promise<boolean>;
82
- steerText: (text: string, files?: readonly FileUIPart[]) => Promise<boolean>;
83
- enqueueText: (text: string, files?: readonly FileUIPart[]) => Promise<boolean>;
90
+ sendText: (
91
+ text: string,
92
+ files?: readonly FileUIPart[],
93
+ capabilities?: readonly RequestedCapability[],
94
+ ) => Promise<boolean>;
95
+ steerText: (
96
+ text: string,
97
+ files?: readonly FileUIPart[],
98
+ capabilities?: readonly RequestedCapability[],
99
+ ) => Promise<boolean>;
100
+ enqueueText: (
101
+ text: string,
102
+ files?: readonly FileUIPart[],
103
+ capabilities?: readonly RequestedCapability[],
104
+ ) => Promise<boolean>;
84
105
  steerQueued: (submissionId: string) => Promise<boolean>;
85
106
  cancelQueued: (submissionId: string) => Promise<boolean>;
86
107
  stop: () => Promise<void>;
108
+ /**
109
+ * 把用户对某张交互卡片的操作回写成那次工具调用的结果。
110
+ *
111
+ * 走的是这条已开的 WS 连接(`agent.call`),不是 HTTP —— ack 与随后流下来的
112
+ * `tool-output-available` chunk 同序到达,UI 状态不会倒挂。
113
+ */
114
+ respondToolInteraction: (
115
+ toolCallId: string,
116
+ response: unknown,
117
+ ) => Promise<boolean>;
118
+ /**
119
+ * 读取该 Session facet 当前真正装配出来的 Runtime。
120
+ *
121
+ * 复用这条已开的 WS 连接,调用方不必为一个只读控制面方法另建 `useAgent`。
122
+ *
123
+ * 与其它动作不同,失败会原样抛出:这是读取而不是提交,调用方需要知道是
124
+ * 装配未就绪还是连接断了,收敛成 `undefined` 会让调试面板无从显示原因。
125
+ */
126
+ getRuntimeAssembly: () => Promise<RuntimeAssemblyView>;
127
+ updateConfig: <Change = unknown>(
128
+ command: unknown,
129
+ ) => Promise<RuntimeConfigUpdateResult<Change>>;
130
+ reloadRuntime: () => Promise<void>;
87
131
  regenerate: () => void;
88
132
  canRetry: boolean;
89
133
  agentToolRuns: AgentToolRunState<ChatMessage["parts"][number]>[];
@@ -100,6 +144,7 @@ export interface ChatRuntime {
100
144
  type SharedChatConnectionOptions = {
101
145
  host?: string;
102
146
  credentials?: RequestCredentials;
147
+ protocols?: string | string[];
103
148
  inboxAgent?: string;
104
149
  sessionAgent?: string;
105
150
  };
@@ -164,10 +209,18 @@ function markChatTurnPhase(
164
209
  function createChatMessage(
165
210
  text: string,
166
211
  files?: readonly FileUIPart[],
212
+ capabilities: readonly RequestedCapability[] = [],
167
213
  ): ChatMessage | null {
168
214
  const normalizedText = text.trim();
169
215
  if (!normalizedText && !files?.length) return null;
170
216
  const createdAt = Date.now();
217
+ const seen = new Set<string>();
218
+ const requestedCapabilities = capabilities.filter((capability) => {
219
+ const key = `${capability.kind}:${capability.name}`;
220
+ if (seen.has(key)) return false;
221
+ seen.add(key);
222
+ return true;
223
+ });
171
224
  return {
172
225
  id: nanoid(),
173
226
  role: "user",
@@ -181,6 +234,7 @@ function createChatMessage(
181
234
  createdAt,
182
235
  authorDisplayName: "You",
183
236
  messageSource: "Web",
237
+ ...(requestedCapabilities.length > 0 ? { requestedCapabilities } : {}),
184
238
  },
185
239
  };
186
240
  }
@@ -246,6 +300,7 @@ export function useUniversalAgentChat(
246
300
  ? { basePath: connection.basePath ?? CURRENT_INBOX_AGENT_PATH }
247
301
  : { name: connection.inboxName }),
248
302
  ...(connection.host === undefined ? {} : { host: connection.host }),
303
+ ...(connection.protocols === undefined ? {} : { protocols: connection.protocols }),
249
304
  sub: [{
250
305
  agent: connection.sessionAgent ?? "UniversalAgent",
251
306
  name: chatId,
@@ -270,10 +325,11 @@ export function useUniversalAgentChat(
270
325
  status: sdkStatus,
271
326
  isStreaming,
272
327
  error,
328
+ clearError,
273
329
  // autonomous responses 纪律(CF 文档采纳 2026-07-05):框架已提供三个流态标志,
274
330
  // 透出供 UI 区分「服务端主动流 vs 用户发起流」+「恢复态」(与 stall 看门狗配套)。
275
331
  isServerStreaming,
276
- isRecovering,
332
+ isRecovering: sdkIsRecovering,
277
333
  isToolContinuation,
278
334
  connectionError,
279
335
  } = useAgentChat<RuntimeState, ChatMessage>({
@@ -286,15 +342,28 @@ export function useUniversalAgentChat(
286
342
  syncMessagesToServer: false,
287
343
  throttle: 100,
288
344
  });
345
+ // The SDK keeps its advisory recovery flag until terminal settlement; a
346
+ // live server stream proves the recovered Turn is executing again.
347
+ const isRecovering = sdkIsRecovering && !isServerStreaming;
289
348
  const visibleMessages = useMemo(
290
349
  () => mergeOptimisticMessages(messages, optimisticMessages),
291
350
  [messages, optimisticMessages],
292
351
  );
293
352
  const authoritativeTurn = facetState?.turn;
294
353
  const stoppedByUser = error?.message === USER_STOP_REASON;
295
- const normalizedSdkStatus = stoppedByUser && sdkStatus === "error"
354
+ const latestMessage = visibleMessages.at(-1);
355
+ const latestTurnStatus = latestMessage?.role === "assistant"
356
+ ? latestMessage.metadata?.turnStatus
357
+ : undefined;
358
+ const hasTerminalMessage = latestTurnStatus === "completed" ||
359
+ latestTurnStatus === "error" ||
360
+ latestTurnStatus === "aborted" ||
361
+ latestTurnStatus === "skipped";
362
+ const normalizedSdkStatus = sdkStatus === "error" &&
363
+ (stoppedByUser || hasTerminalMessage)
296
364
  ? "ready"
297
365
  : sdkStatus;
366
+ const sdkError = stoppedByUser || hasTerminalMessage ? undefined : error;
298
367
  // RPC admissions bypass useChat's request lifecycle. Project the durable
299
368
  // Turn here so presentation stays correct without a second send path.
300
369
  const authoritativeStatus =
@@ -392,12 +461,17 @@ export function useUniversalAgentChat(
392
461
  queued,
393
462
  };
394
463
  }, [authoritativeTurn, visibleMessages, optimisticQueued]);
395
- const turnActive = Boolean(turn?.activeSubmissionId) ||
464
+ const sdkTurnActive =
396
465
  status === "submitted" ||
397
466
  status === "streaming" ||
398
467
  isServerStreaming ||
399
468
  isRecovering ||
400
469
  isToolContinuation;
470
+ const pendingLocalTurn = optimisticMessages.length > 0 &&
471
+ (status === "submitted" || status === "streaming");
472
+ const turnActive = Boolean(turn?.activeSubmissionId) ||
473
+ pendingLocalTurn ||
474
+ (authoritativeTurn === undefined && sdkTurnActive);
401
475
  const canSteer = turnActive &&
402
476
  (!turn?.activeSubmissionId || turn.steerable);
403
477
 
@@ -426,6 +500,7 @@ export function useUniversalAgentChat(
426
500
  projection: "message" | "queue",
427
501
  ): Promise<boolean> => {
428
502
  setDispatchError(undefined);
503
+ clearError();
429
504
  failedDispatchRef.current = undefined;
430
505
  setCanRetry(false);
431
506
  if (projection === "message" && delivery === "enqueue") {
@@ -441,6 +516,7 @@ export function useUniversalAgentChat(
441
516
  };
442
517
  }
443
518
  if (projection === "message") {
519
+ activeSubmissionIdRef.current = undefined;
444
520
  setOptimisticMessages((current) =>
445
521
  current.some(({ id }) => id === message.id)
446
522
  ? current
@@ -517,13 +593,14 @@ export function useUniversalAgentChat(
517
593
  );
518
594
  return false;
519
595
  }
520
- }, [chatId, dispatchMessage]);
596
+ }, [chatId, clearError, dispatchMessage]);
521
597
 
522
598
  const sendText = useCallback(async (
523
599
  text: string,
524
600
  files?: readonly FileUIPart[],
601
+ capabilities?: readonly RequestedCapability[],
525
602
  ): Promise<boolean> => {
526
- const message = createChatMessage(text, files);
603
+ const message = createChatMessage(text, files, capabilities);
527
604
  return message
528
605
  ? submitMessage(message, "enqueue", "message")
529
606
  : false;
@@ -532,21 +609,28 @@ export function useUniversalAgentChat(
532
609
  const dispatchText = useCallback(async (
533
610
  text: string,
534
611
  files: readonly FileUIPart[] | undefined,
612
+ capabilities: readonly RequestedCapability[] | undefined,
535
613
  delivery: MessageDelivery,
536
614
  projection: "message" | "queue",
537
615
  ): Promise<boolean> => {
538
- const message = createChatMessage(text, files);
616
+ const message = createChatMessage(text, files, capabilities);
539
617
  return message ? submitMessage(message, delivery, projection) : false;
540
618
  }, [submitMessage]);
541
619
 
542
620
  const steerText = useCallback(
543
- (text: string, files?: readonly FileUIPart[]) =>
544
- dispatchText(text, files, "steer", "message"),
621
+ (
622
+ text: string,
623
+ files?: readonly FileUIPart[],
624
+ capabilities?: readonly RequestedCapability[],
625
+ ) => dispatchText(text, files, capabilities, "steer", "message"),
545
626
  [dispatchText],
546
627
  );
547
628
  const enqueueText = useCallback(
548
- (text: string, files?: readonly FileUIPart[]) =>
549
- dispatchText(text, files, "enqueue", "queue"),
629
+ (
630
+ text: string,
631
+ files?: readonly FileUIPart[],
632
+ capabilities?: readonly RequestedCapability[],
633
+ ) => dispatchText(text, files, capabilities, "enqueue", "queue"),
550
634
  [dispatchText],
551
635
  );
552
636
  const steerQueued = useCallback(async (submissionId: string) => {
@@ -597,22 +681,37 @@ export function useUniversalAgentChat(
597
681
  }, []);
598
682
 
599
683
  const stop = useCallback(async () => {
684
+ const requestId = authoritativeTurn?.activeRequestId;
600
685
  const submissionId = authoritativeTurn?.activeSubmissionId ??
601
686
  activeSubmissionIdRef.current;
602
- if (!submissionId) return;
603
687
  setDispatchError(undefined);
604
688
  try {
605
- const result = await agentRef.current.call<{ ok: boolean }>(
606
- "cancelSubmissionById",
607
- [submissionId, USER_STOP_REASON],
608
- );
689
+ if (requestId) {
690
+ agentRef.current.send(JSON.stringify({
691
+ type: MessageType.CF_AGENT_CHAT_REQUEST_CANCEL,
692
+ id: requestId,
693
+ }));
694
+ return;
695
+ }
696
+ const result = submissionId
697
+ ? await agentRef.current.call<{ ok: boolean }>(
698
+ "cancelSubmissionById",
699
+ [submissionId, USER_STOP_REASON],
700
+ )
701
+ : await agentRef.current.call<{ ok: boolean }>(
702
+ "stopTurn",
703
+ [undefined, USER_STOP_REASON],
704
+ );
609
705
  if (!result.ok) setDispatchError("The active Turn could not be stopped");
610
706
  } catch (cause) {
611
707
  setDispatchError(
612
708
  cause instanceof Error ? cause.message : String(cause),
613
709
  );
614
710
  }
615
- }, [authoritativeTurn?.activeSubmissionId]);
711
+ }, [
712
+ authoritativeTurn?.activeRequestId,
713
+ authoritativeTurn?.activeSubmissionId,
714
+ ]);
616
715
 
617
716
  const retry = useCallback(() => {
618
717
  const failed = failedDispatchRef.current;
@@ -624,12 +723,55 @@ export function useUniversalAgentChat(
624
723
  );
625
724
  }, [submitMessage]);
626
725
 
726
+ // 交互卡片的写回口。失败只返回 false —— 调用方(块组件)自己决定怎么提示;
727
+ // 权威状态永远来自 part 的 state,不来自这里的返回值。
728
+ const respondToolInteraction = useCallback(async (
729
+ toolCallId: string,
730
+ response: unknown,
731
+ ): Promise<boolean> => {
732
+ try {
733
+ const receipt = await agentRef.current.call<{ ok: boolean }>(
734
+ "respondToolInteraction",
735
+ [toolCallId, response],
736
+ );
737
+ return receipt.ok;
738
+ } catch {
739
+ return false;
740
+ }
741
+ }, []);
742
+
743
+ // 只读控制面:Runtime 尚未装配时由 Agent 侧 `ensureRuntimeReady` 负责等待,
744
+ // 这里不缓存结果 —— 换模型、加技能或 reload 之后调用方要拿到的是新装配。
745
+ const getRuntimeAssembly = useCallback(
746
+ () => agentRef.current.call<RuntimeAssemblyView>("getRuntimeAssembly", []),
747
+ [],
748
+ );
749
+ const updateConfig = useCallback(
750
+ <Change,>(command: unknown) =>
751
+ agentRef.current.call<RuntimeConfigUpdateResult<Change>>(
752
+ "updateConfig",
753
+ [command],
754
+ ),
755
+ [],
756
+ );
757
+ const reloadRuntime = useCallback(
758
+ () => agentRef.current.call<void>(
759
+ "reloadRuntime",
760
+ [undefined, { force: true }],
761
+ ),
762
+ [],
763
+ );
764
+
627
765
  return {
766
+ respondToolInteraction,
767
+ getRuntimeAssembly,
768
+ updateConfig,
769
+ reloadRuntime,
628
770
  messages: visibleMessages,
629
771
  status,
630
772
  runtimeLoad: facetState?.runtimeLoad,
631
773
  isStreaming,
632
- error: dispatchError ?? (stoppedByUser ? undefined : error) ??
774
+ error: dispatchError ?? sdkError ??
633
775
  connectionError ?? undefined,
634
776
  sendText,
635
777
  steerText,