@assistant-ui/eve 0.0.11 → 0.0.13

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.
@@ -1 +1 @@
1
- {"version":3,"file":"useEveAgentRuntime.d.ts","names":[],"sources":["../src/useEveAgentRuntime.ts"],"mappings":";;;KAiEY,4BAA4B,KACtC,mBAAmB,8BAGnB;WACW;aAEM,cAAc;aACd,SAAS;aACT,YAAY;aACZ,QAAQ;aACR,WAAW;;;;;;;;;;cAYjB,qBAAsB,UAAS,2DAA8B"}
1
+ {"version":3,"file":"useEveAgentRuntime.d.ts","names":[],"sources":["../src/useEveAgentRuntime.ts"],"mappings":";;;KAsGY,4BAA4B,KACtC,mBAAmB,8BAGnB;WACW;aAEM,cAAc;aACd,SAAS;aACT,YAAY;aACZ,QAAQ;aACR,WAAW;;;;;;;;;;cAYjB,qBAAsB,UAAS,2DAA8B"}
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { convertEveMessages, getEveMessageContent, toEveInputResponse } from "./convertEveMessages.js";
3
- import { fromThreadMessageLike, generateId, pickExternalStoreSharedOptions } from "@assistant-ui/core";
3
+ import { MessageNotSentError, fromThreadMessageLike, generateId, pickExternalStoreSharedOptions } from "@assistant-ui/core";
4
4
  import { useEffect, useMemo, useRef, useState } from "react";
5
5
  import { useExternalStoreRuntime, useRuntimeAdapters } from "@assistant-ui/core/react";
6
6
  import { useEveAgent } from "eve/react";
@@ -9,7 +9,22 @@ const USER_STAGED_STATUS = {
9
9
  type: "complete",
10
10
  reason: "unknown"
11
11
  };
12
- const sendCancelledError = /* @__PURE__ */ new Error("eve send was dropped because the run was cancelled.");
12
+ const sendCancelledError = new MessageNotSentError("eve send was dropped because the run was cancelled.");
13
+ const sendAbandonedError = /* @__PURE__ */ new Error("eve send was dropped because the runtime unmounted.");
14
+ const isDroppedSend = (error) => error === sendCancelledError || error === sendAbandonedError;
15
+ const reportEveLifecycleCallbackError = (name, error) => {
16
+ console.error(`[assistant-ui/eve] ${name} callback threw an error`, error);
17
+ };
18
+ const invokeEveLifecycleCallback = (name, callback, value) => {
19
+ if (!callback) return;
20
+ try {
21
+ Promise.resolve(callback(value)).catch((error) => {
22
+ reportEveLifecycleCallbackError(name, error);
23
+ });
24
+ } catch (error) {
25
+ reportEveLifecycleCallbackError(name, error);
26
+ }
27
+ };
13
28
  const hasRunConfig = (runConfig) => runConfig?.custom !== void 0 && Object.keys(runConfig.custom).length > 0;
14
29
  /**
15
30
  * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own
@@ -27,14 +42,17 @@ const toEveClientContext = (runConfig) => hasRunConfig(runConfig) ? { clientCont
27
42
  */
28
43
  const useEveAgentRuntime = (options = {}) => {
29
44
  const { adapters, isDisabled: _isDisabled, isSendDisabled: _isSendDisabled, suggestions: _suggestions, unstable_capabilities: _unstable_capabilities, ...agentOptions } = options;
30
- const { onFinish } = agentOptions;
45
+ const { onError, onEvent, onFinish, onSessionChange } = agentOptions;
31
46
  const lastFinishStatusRef = useRef(null);
32
47
  const agent = useEveAgent({
33
48
  ...agentOptions,
49
+ ...onError ? { onError: (error) => invokeEveLifecycleCallback("onError", onError, error) } : {},
50
+ ...onEvent ? { onEvent: (event) => invokeEveLifecycleCallback("onEvent", onEvent, event) } : {},
34
51
  onFinish: (snapshot) => {
35
52
  lastFinishStatusRef.current = snapshot.status;
36
- onFinish?.(snapshot);
37
- }
53
+ invokeEveLifecycleCallback("onFinish", onFinish, snapshot);
54
+ },
55
+ ...onSessionChange ? { onSessionChange: (session) => invokeEveLifecycleCallback("onSessionChange", onSessionChange, session) } : {}
38
56
  });
39
57
  const runtimeAdapters = useRuntimeAdapters();
40
58
  const [toolStatuses, setToolStatuses] = useState({});
@@ -68,17 +86,20 @@ const useEveAgentRuntime = (options = {}) => {
68
86
  messagesRef.current = messages;
69
87
  const sendChainRef = useRef(Promise.resolve());
70
88
  const sendEpochRef = useRef(0);
89
+ const isMountedRef = useRef(true);
71
90
  const enqueueSend = (payload) => {
72
91
  const epoch = sendEpochRef.current;
73
92
  const next = sendChainRef.current.then(() => {
74
- if (epoch !== sendEpochRef.current) throw sendCancelledError;
93
+ if (epoch !== sendEpochRef.current) throw isMountedRef.current ? sendCancelledError : sendAbandonedError;
75
94
  return agent.send(payload);
76
95
  });
77
96
  sendChainRef.current = next.catch(() => {});
78
97
  return next;
79
98
  };
80
99
  useEffect(() => {
100
+ isMountedRef.current = true;
81
101
  return () => {
102
+ isMountedRef.current = false;
82
103
  sendEpochRef.current += 1;
83
104
  };
84
105
  }, []);
@@ -135,7 +156,8 @@ const useEveAgentRuntime = (options = {}) => {
135
156
  ...toEveClientContext(message.runConfig)
136
157
  });
137
158
  } catch (error) {
138
- if (error !== sendCancelledError) throw error;
159
+ if (error === sendAbandonedError) return;
160
+ throw error;
139
161
  }
140
162
  },
141
163
  ...stagedMessages ? { onReload: async (parentId, config) => {
@@ -159,7 +181,7 @@ const useEveAgentRuntime = (options = {}) => {
159
181
  stagedInputsRef.current.set(stagedMessage.id, input);
160
182
  messagesRef.current = previousMessages;
161
183
  setStagedMessages(previousMessages);
162
- if (error === sendCancelledError) return;
184
+ if (isDroppedSend(error)) return;
163
185
  throw error;
164
186
  }
165
187
  if (lastFinishStatusRef.current === "error") return;
@@ -174,7 +196,7 @@ const useEveAgentRuntime = (options = {}) => {
174
196
  try {
175
197
  await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
176
198
  } catch (error) {
177
- if (error !== sendCancelledError) throw error;
199
+ if (!isDroppedSend(error)) throw error;
178
200
  }
179
201
  }
180
202
  });
@@ -1 +1 @@
1
- {"version":3,"file":"useEveAgentRuntime.js","names":[],"sources":["../src/useEveAgentRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n fromThreadMessageLike,\n generateId,\n pickExternalStoreSharedOptions,\n type AppendMessage,\n type AttachmentAdapter,\n type DictationAdapter,\n type ExternalStoreSharedOptions,\n type FeedbackAdapter,\n type RealtimeVoiceAdapter,\n type SpeechSynthesisAdapter,\n type ThreadMessage,\n type ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport {\n useExternalStoreRuntime,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport {\n useEveAgent,\n type EveMessageData,\n type UseEveAgentOptions,\n type UseEveAgentStatus,\n} from \"eve/react\";\nimport type { SendTurnPayload } from \"eve/client\";\nimport {\n convertEveMessages,\n getEveMessageContent,\n toEveInputResponse,\n} from \"./convertEveMessages\";\n\nconst USER_STAGED_STATUS = {\n type: \"complete\",\n reason: \"unknown\",\n} as const;\n\nconst sendCancelledError = new Error(\n \"eve send was dropped because the run was cancelled.\",\n);\n\nconst hasRunConfig = (\n runConfig: AppendMessage[\"runConfig\"],\n): runConfig is NonNullable<AppendMessage[\"runConfig\"]> =>\n runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;\n\n/**\n * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own\n * namespace and serializes it into a model-visible context message, so sending\n * the assistant-ui envelope would surface a literal `\"custom\"` key in the\n * prompt and to every eve-side handler.\n */\nconst toEveClientContext = (\n runConfig: AppendMessage[\"runConfig\"],\n): Pick<SendTurnPayload, \"clientContext\"> =>\n hasRunConfig(runConfig)\n ? {\n clientContext: runConfig.custom as NonNullable<\n SendTurnPayload[\"clientContext\"]\n >,\n }\n : {};\n\nexport type UseEveAgentRuntimeOptions = Omit<\n UseEveAgentOptions<EveMessageData>,\n \"reducer\"\n> &\n ExternalStoreSharedOptions & {\n readonly adapters?:\n | {\n readonly attachments?: AttachmentAdapter | undefined;\n readonly speech?: SpeechSynthesisAdapter | undefined;\n readonly dictation?: DictationAdapter | undefined;\n readonly voice?: RealtimeVoiceAdapter | undefined;\n readonly feedback?: FeedbackAdapter | undefined;\n }\n | undefined;\n };\n\n/**\n * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.\n *\n * The runtime renders Eve messages, forwards new user messages to the Eve\n * session, supports cancellation, and maps Eve input requests to assistant-ui\n * tool approval UI.\n */\nexport const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {\n const {\n adapters,\n isDisabled: _isDisabled,\n isSendDisabled: _isSendDisabled,\n suggestions: _suggestions,\n unstable_capabilities: _unstable_capabilities,\n ...agentOptions\n } = options;\n true satisfies keyof typeof agentOptions &\n keyof ExternalStoreSharedOptions extends never\n ? true\n : never;\n\n const { onFinish } = agentOptions;\n const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);\n const agent = useEveAgent({\n ...agentOptions,\n onFinish: (snapshot) => {\n lastFinishStatusRef.current = snapshot.status;\n onFinish?.(snapshot);\n },\n });\n const runtimeAdapters = useRuntimeAdapters();\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n const [stagedMessages, setStagedMessages] = useState<ThreadMessage[] | null>(\n null,\n );\n const createdAtByMessageIdRef = useRef(new Map<string, Date>());\n const stagedInputsRef = useRef(\n new Map<\n string,\n { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] }\n >(),\n );\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (status) => status?.type === \"executing\",\n );\n const isRunning =\n agent.status === \"submitted\" ||\n agent.status === \"streaming\" ||\n hasExecutingTools;\n\n const convertedMessages = useMemo(() => {\n const createdAtByMessageId = createdAtByMessageIdRef.current;\n const messageIds = new Set(\n agent.data.messages.map((message) => message.id),\n );\n for (const messageId of createdAtByMessageId.keys()) {\n if (!messageIds.has(messageId)) createdAtByMessageId.delete(messageId);\n }\n\n return convertEveMessages(agent.data, {\n isRunning,\n error: agent.error,\n getCreatedAt: (message) => {\n const existing = createdAtByMessageId.get(message.id);\n if (existing) return existing;\n\n const createdAt = new Date();\n createdAtByMessageId.set(message.id, createdAt);\n return createdAt;\n },\n });\n }, [agent.data, agent.error, isRunning]);\n\n const messages = stagedMessages ?? convertedMessages;\n const messagesRef = useRef(messages);\n messagesRef.current = messages;\n\n // Upstream `EveAgentStore.send` rejects while a turn is in flight and only\n // resolves once the turn's stream parks, so a pending chain link is exactly\n // an active turn; chaining every send serializes them without watching\n // status.\n const sendChainRef = useRef<Promise<void>>(Promise.resolve());\n const sendEpochRef = useRef(0);\n\n const enqueueSend = (payload: Parameters<typeof agent.send>[0]) => {\n const epoch = sendEpochRef.current;\n const next = sendChainRef.current.then(() => {\n if (epoch !== sendEpochRef.current) throw sendCancelledError;\n return agent.send(payload);\n });\n sendChainRef.current = next.catch(() => {});\n return next;\n };\n\n // The store outlives the component (useEveAgent holds it in a ref with no\n // cleanup), so queued sends must not fire server turns after unmount.\n useEffect(() => {\n return () => {\n sendEpochRef.current += 1;\n };\n }, []);\n\n useEffect(() => {\n if (stagedInputsRef.current.size === 0) return;\n const baseIds = new Set(convertedMessages.map((message) => message.id));\n const remaining = messagesRef.current.filter(\n (message) =>\n stagedInputsRef.current.has(message.id) && !baseIds.has(message.id),\n );\n setStagedMessages(\n remaining.length > 0 ? [...convertedMessages, ...remaining] : null,\n );\n }, [convertedMessages]);\n\n const getStagedRun = (parentId: string | null) => {\n if (!parentId || !stagedInputsRef.current.has(parentId)) return null;\n const staged: {\n message: ThreadMessage;\n input: { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] };\n }[] = [];\n for (const message of messagesRef.current) {\n const input = stagedInputsRef.current.get(message.id);\n if (input) staged.push({ message, input });\n if (message.id === parentId) break;\n }\n return staged;\n };\n\n const stageUserMessage = (message: AppendMessage) => {\n const threadMessage = fromThreadMessageLike(\n message,\n generateId(),\n USER_STAGED_STATUS,\n );\n stagedInputsRef.current.set(threadMessage.id, {\n message,\n runConfig: message.runConfig,\n });\n const nextMessages = [...messagesRef.current, threadMessage];\n messagesRef.current = nextMessages;\n setStagedMessages(nextMessages);\n };\n\n return useExternalStoreRuntime({\n ...pickExternalStoreSharedOptions(options),\n messages,\n isRunning,\n unstable_enableToolInvocations: true,\n setToolStatuses,\n adapters: {\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n },\n onNew: async (message) => {\n if (!(message.startRun ?? message.role === \"user\")) {\n stageUserMessage(message);\n return;\n }\n try {\n await enqueueSend({\n message: getEveMessageContent(message),\n ...toEveClientContext(message.runConfig),\n });\n } catch (error) {\n if (error !== sendCancelledError) throw error;\n }\n },\n ...(stagedMessages\n ? {\n onReload: async (parentId: string | null, config) => {\n const stagedRun = getStagedRun(parentId);\n if (!stagedRun)\n throw new Error(\"Runtime does not support reloading messages.\");\n const epoch = sendEpochRef.current;\n for (const { message: stagedMessage, input } of stagedRun) {\n if (epoch !== sendEpochRef.current) return;\n const previousMessages = messagesRef.current;\n stagedInputsRef.current.delete(stagedMessage.id);\n const nextMessages = previousMessages.filter(\n (message) => message.id !== stagedMessage.id,\n );\n messagesRef.current = nextMessages;\n setStagedMessages(\n stagedInputsRef.current.size > 0 ? nextMessages : null,\n );\n // The reload config belongs to the message being reloaded; the\n // drafts promoted ahead of it keep the config they were staged\n // with, or reloading the tail would rewrite their context too.\n const runConfig =\n stagedMessage.id === parentId && hasRunConfig(config.runConfig)\n ? config.runConfig\n : input.runConfig;\n try {\n await enqueueSend({\n message: getEveMessageContent(input.message),\n ...toEveClientContext(runConfig),\n });\n } catch (error) {\n stagedInputsRef.current.set(stagedMessage.id, input);\n messagesRef.current = previousMessages;\n setStagedMessages(previousMessages);\n if (error === sendCancelledError) return;\n throw error;\n }\n if (lastFinishStatusRef.current === \"error\") return;\n }\n },\n }\n : {}),\n onCancel: () => {\n sendEpochRef.current += 1;\n agent.stop();\n return Promise.resolve();\n },\n onRespondToToolApproval: async (response) => {\n try {\n await enqueueSend({ inputResponses: [toEveInputResponse(response)] });\n } catch (error) {\n if (error !== sendCancelledError) throw error;\n }\n },\n });\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"useEveAgentRuntime.js","names":[],"sources":["../src/useEveAgentRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n fromThreadMessageLike,\n generateId,\n MessageNotSentError,\n pickExternalStoreSharedOptions,\n type AppendMessage,\n type AttachmentAdapter,\n type DictationAdapter,\n type ExternalStoreSharedOptions,\n type FeedbackAdapter,\n type RealtimeVoiceAdapter,\n type SpeechSynthesisAdapter,\n type ThreadMessage,\n type ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport {\n useExternalStoreRuntime,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport {\n useEveAgent,\n type EveMessageData,\n type UseEveAgentOptions,\n type UseEveAgentStatus,\n} from \"eve/react\";\nimport type { SendTurnPayload } from \"eve/client\";\nimport {\n convertEveMessages,\n getEveMessageContent,\n toEveInputResponse,\n} from \"./convertEveMessages\";\n\nconst USER_STAGED_STATUS = {\n type: \"complete\",\n reason: \"unknown\",\n} as const;\n\nconst sendCancelledError = new MessageNotSentError(\n \"eve send was dropped because the run was cancelled.\",\n);\n\nconst sendAbandonedError = new Error(\n \"eve send was dropped because the runtime unmounted.\",\n);\n\nconst isDroppedSend = (error: unknown) =>\n error === sendCancelledError || error === sendAbandonedError;\n\ntype EveLifecycleCallbackName =\n | \"onError\"\n | \"onEvent\"\n | \"onFinish\"\n | \"onSessionChange\";\n\nconst reportEveLifecycleCallbackError = (\n name: EveLifecycleCallbackName,\n error: unknown,\n) => {\n console.error(`[assistant-ui/eve] ${name} callback threw an error`, error);\n};\n\nconst invokeEveLifecycleCallback = <T>(\n name: EveLifecycleCallbackName,\n callback: ((value: T) => unknown) | undefined,\n value: T,\n) => {\n if (!callback) return;\n\n try {\n void Promise.resolve(callback(value)).catch((error) => {\n reportEveLifecycleCallbackError(name, error);\n });\n } catch (error) {\n reportEveLifecycleCallbackError(name, error);\n }\n};\n\nconst hasRunConfig = (\n runConfig: AppendMessage[\"runConfig\"],\n): runConfig is NonNullable<AppendMessage[\"runConfig\"]> =>\n runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;\n\n/**\n * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own\n * namespace and serializes it into a model-visible context message, so sending\n * the assistant-ui envelope would surface a literal `\"custom\"` key in the\n * prompt and to every eve-side handler.\n */\nconst toEveClientContext = (\n runConfig: AppendMessage[\"runConfig\"],\n): Pick<SendTurnPayload, \"clientContext\"> =>\n hasRunConfig(runConfig)\n ? {\n clientContext: runConfig.custom as NonNullable<\n SendTurnPayload[\"clientContext\"]\n >,\n }\n : {};\n\nexport type UseEveAgentRuntimeOptions = Omit<\n UseEveAgentOptions<EveMessageData>,\n \"reducer\"\n> &\n ExternalStoreSharedOptions & {\n readonly adapters?:\n | {\n readonly attachments?: AttachmentAdapter | undefined;\n readonly speech?: SpeechSynthesisAdapter | undefined;\n readonly dictation?: DictationAdapter | undefined;\n readonly voice?: RealtimeVoiceAdapter | undefined;\n readonly feedback?: FeedbackAdapter | undefined;\n }\n | undefined;\n };\n\n/**\n * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.\n *\n * The runtime renders Eve messages, forwards new user messages to the Eve\n * session, supports cancellation, and maps Eve input requests to assistant-ui\n * tool approval UI.\n */\nexport const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {\n const {\n adapters,\n isDisabled: _isDisabled,\n isSendDisabled: _isSendDisabled,\n suggestions: _suggestions,\n unstable_capabilities: _unstable_capabilities,\n ...agentOptions\n } = options;\n true satisfies keyof typeof agentOptions &\n keyof ExternalStoreSharedOptions extends never\n ? true\n : never;\n\n const { onError, onEvent, onFinish, onSessionChange } = agentOptions;\n const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);\n const agent = useEveAgent({\n ...agentOptions,\n ...(onError\n ? {\n onError: (error) =>\n invokeEveLifecycleCallback(\"onError\", onError, error),\n }\n : {}),\n ...(onEvent\n ? {\n onEvent: (event) =>\n invokeEveLifecycleCallback(\"onEvent\", onEvent, event),\n }\n : {}),\n onFinish: (snapshot) => {\n lastFinishStatusRef.current = snapshot.status;\n invokeEveLifecycleCallback(\"onFinish\", onFinish, snapshot);\n },\n ...(onSessionChange\n ? {\n onSessionChange: (session) =>\n invokeEveLifecycleCallback(\n \"onSessionChange\",\n onSessionChange,\n session,\n ),\n }\n : {}),\n });\n const runtimeAdapters = useRuntimeAdapters();\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n const [stagedMessages, setStagedMessages] = useState<ThreadMessage[] | null>(\n null,\n );\n const createdAtByMessageIdRef = useRef(new Map<string, Date>());\n const stagedInputsRef = useRef(\n new Map<\n string,\n { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] }\n >(),\n );\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (status) => status?.type === \"executing\",\n );\n const isRunning =\n agent.status === \"submitted\" ||\n agent.status === \"streaming\" ||\n hasExecutingTools;\n\n const convertedMessages = useMemo(() => {\n const createdAtByMessageId = createdAtByMessageIdRef.current;\n const messageIds = new Set(\n agent.data.messages.map((message) => message.id),\n );\n for (const messageId of createdAtByMessageId.keys()) {\n if (!messageIds.has(messageId)) createdAtByMessageId.delete(messageId);\n }\n\n return convertEveMessages(agent.data, {\n isRunning,\n error: agent.error,\n getCreatedAt: (message) => {\n const existing = createdAtByMessageId.get(message.id);\n if (existing) return existing;\n\n const createdAt = new Date();\n createdAtByMessageId.set(message.id, createdAt);\n return createdAt;\n },\n });\n }, [agent.data, agent.error, isRunning]);\n\n const messages = stagedMessages ?? convertedMessages;\n const messagesRef = useRef(messages);\n messagesRef.current = messages;\n\n // Upstream `EveAgentStore.send` rejects while a turn is in flight and only\n // resolves once the turn's stream parks, so a pending chain link is exactly\n // an active turn; chaining every send serializes them without watching\n // status.\n const sendChainRef = useRef<Promise<void>>(Promise.resolve());\n const sendEpochRef = useRef(0);\n const isMountedRef = useRef(true);\n\n const enqueueSend = (payload: Parameters<typeof agent.send>[0]) => {\n const epoch = sendEpochRef.current;\n const next = sendChainRef.current.then(() => {\n if (epoch !== sendEpochRef.current)\n throw isMountedRef.current ? sendCancelledError : sendAbandonedError;\n return agent.send(payload);\n });\n sendChainRef.current = next.catch(() => {});\n return next;\n };\n\n // The store outlives the component (useEveAgent holds it in a ref with no\n // cleanup), so queued sends must not fire server turns after unmount. The\n // flag separates that teardown from a user cancel, and is re-armed in setup\n // for a remounted tree.\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n sendEpochRef.current += 1;\n };\n }, []);\n\n useEffect(() => {\n if (stagedInputsRef.current.size === 0) return;\n const baseIds = new Set(convertedMessages.map((message) => message.id));\n const remaining = messagesRef.current.filter(\n (message) =>\n stagedInputsRef.current.has(message.id) && !baseIds.has(message.id),\n );\n setStagedMessages(\n remaining.length > 0 ? [...convertedMessages, ...remaining] : null,\n );\n }, [convertedMessages]);\n\n const getStagedRun = (parentId: string | null) => {\n if (!parentId || !stagedInputsRef.current.has(parentId)) return null;\n const staged: {\n message: ThreadMessage;\n input: { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] };\n }[] = [];\n for (const message of messagesRef.current) {\n const input = stagedInputsRef.current.get(message.id);\n if (input) staged.push({ message, input });\n if (message.id === parentId) break;\n }\n return staged;\n };\n\n const stageUserMessage = (message: AppendMessage) => {\n const threadMessage = fromThreadMessageLike(\n message,\n generateId(),\n USER_STAGED_STATUS,\n );\n stagedInputsRef.current.set(threadMessage.id, {\n message,\n runConfig: message.runConfig,\n });\n const nextMessages = [...messagesRef.current, threadMessage];\n messagesRef.current = nextMessages;\n setStagedMessages(nextMessages);\n };\n\n return useExternalStoreRuntime({\n ...pickExternalStoreSharedOptions(options),\n messages,\n isRunning,\n unstable_enableToolInvocations: true,\n setToolStatuses,\n adapters: {\n attachments: adapters?.attachments ?? runtimeAdapters?.attachments,\n speech: adapters?.speech,\n dictation: adapters?.dictation,\n voice: adapters?.voice,\n feedback: adapters?.feedback,\n },\n onNew: async (message) => {\n if (!(message.startRun ?? message.role === \"user\")) {\n stageUserMessage(message);\n return;\n }\n try {\n await enqueueSend({\n message: getEveMessageContent(message),\n ...toEveClientContext(message.runConfig),\n });\n } catch (error) {\n // A cancelled send never reached the session, so it rethrows for the\n // composer to take the draft back; an unmounted one has no composer\n // left to restore.\n if (error === sendAbandonedError) return;\n throw error;\n }\n },\n ...(stagedMessages\n ? {\n onReload: async (parentId: string | null, config) => {\n const stagedRun = getStagedRun(parentId);\n if (!stagedRun)\n throw new Error(\"Runtime does not support reloading messages.\");\n const epoch = sendEpochRef.current;\n for (const { message: stagedMessage, input } of stagedRun) {\n if (epoch !== sendEpochRef.current) return;\n const previousMessages = messagesRef.current;\n stagedInputsRef.current.delete(stagedMessage.id);\n const nextMessages = previousMessages.filter(\n (message) => message.id !== stagedMessage.id,\n );\n messagesRef.current = nextMessages;\n setStagedMessages(\n stagedInputsRef.current.size > 0 ? nextMessages : null,\n );\n // The reload config belongs to the message being reloaded; the\n // drafts promoted ahead of it keep the config they were staged\n // with, or reloading the tail would rewrite their context too.\n const runConfig =\n stagedMessage.id === parentId && hasRunConfig(config.runConfig)\n ? config.runConfig\n : input.runConfig;\n try {\n await enqueueSend({\n message: getEveMessageContent(input.message),\n ...toEveClientContext(runConfig),\n });\n } catch (error) {\n stagedInputsRef.current.set(stagedMessage.id, input);\n messagesRef.current = previousMessages;\n setStagedMessages(previousMessages);\n if (isDroppedSend(error)) return;\n throw error;\n }\n if (lastFinishStatusRef.current === \"error\") return;\n }\n },\n }\n : {}),\n onCancel: () => {\n sendEpochRef.current += 1;\n agent.stop();\n return Promise.resolve();\n },\n onRespondToToolApproval: async (response) => {\n try {\n await enqueueSend({ inputResponses: [toEveInputResponse(response)] });\n } catch (error) {\n if (!isDroppedSend(error)) throw error;\n }\n },\n });\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/eve",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Eve runtime adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "eve",
@@ -28,7 +28,7 @@
28
28
  ],
29
29
  "sideEffects": false,
30
30
  "dependencies": {
31
- "@assistant-ui/core": "^0.3.10"
31
+ "@assistant-ui/core": "^0.3.13"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@types/react": "*",
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
- import { describe, expect, it, vi } from "vitest";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  const { mockUseEveAgent } = vi.hoisted(() => ({
7
7
  mockUseEveAgent: vi.fn(),
@@ -39,7 +39,60 @@ const createAgent = (overrides: Record<string, unknown>) => ({
39
39
  ...overrides,
40
40
  });
41
41
 
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ });
45
+
42
46
  describe("useEveAgentRuntime status forwarding", () => {
47
+ it.each(
48
+ (["onError", "onEvent", "onFinish", "onSessionChange"] as const).flatMap(
49
+ (callbackName) =>
50
+ (["throws", "rejects"] as const).map(
51
+ (failureMode) => [callbackName, failureMode] as const,
52
+ ),
53
+ ),
54
+ )(
55
+ "isolates %s callback errors when it %s",
56
+ async (callbackName, failureMode) => {
57
+ const callbackError = new Error(`${callbackName} failed`);
58
+ const callback = vi.fn(() => {
59
+ if (failureMode === "throws") throw callbackError;
60
+ return Promise.reject(callbackError);
61
+ });
62
+ const agent = createAgent({ data: { messages: [] } });
63
+ let capturedOptions: Record<
64
+ string,
65
+ ((value: unknown) => void) | undefined
66
+ > = {};
67
+ mockUseEveAgent.mockImplementation((options) => {
68
+ capturedOptions = options as typeof capturedOptions;
69
+ return agent as never;
70
+ });
71
+ const consoleError = vi
72
+ .spyOn(console, "error")
73
+ .mockImplementation(() => {});
74
+
75
+ renderHook(() =>
76
+ useEveAgentRuntime({ [callbackName]: callback } as never),
77
+ );
78
+
79
+ const value =
80
+ callbackName === "onFinish"
81
+ ? { status: "ready" }
82
+ : callbackName === "onError"
83
+ ? new Error("run failed")
84
+ : {};
85
+ expect(() => capturedOptions[callbackName]?.(value)).not.toThrow();
86
+ expect(callback).toHaveBeenCalledWith(value);
87
+ await waitFor(() => {
88
+ expect(consoleError).toHaveBeenCalledWith(
89
+ `[assistant-ui/eve] ${callbackName} callback threw an error`,
90
+ callbackError,
91
+ );
92
+ });
93
+ },
94
+ );
95
+
43
96
  it("maps the session error onto the interrupted assistant message", () => {
44
97
  mockUseEveAgent.mockReturnValue(
45
98
  createAgent({ status: "error", error: new Error("boom") }) as never,
@@ -858,6 +911,142 @@ describe("useEveAgentRuntime concurrent sends", () => {
858
911
  expect(send).toHaveBeenCalledTimes(1);
859
912
  });
860
913
 
914
+ it("returns a cancelled queued send to the composer", async () => {
915
+ let resolveFirstSend!: () => void;
916
+ const send = vi
917
+ .fn()
918
+ .mockImplementationOnce(
919
+ () =>
920
+ new Promise<void>((resolve) => {
921
+ resolveFirstSend = resolve;
922
+ }),
923
+ )
924
+ .mockResolvedValue(undefined);
925
+ const agent = createAgent({ data: settledData, send });
926
+ mockUseEveAgent.mockReturnValue(agent as never);
927
+ const { result } = renderHook(() => useEveAgentRuntime());
928
+
929
+ await act(async () => {
930
+ result.current.thread.append({
931
+ role: "user",
932
+ content: [{ type: "text", text: "first" }],
933
+ });
934
+ });
935
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
936
+
937
+ await act(async () => {
938
+ result.current.thread.composer.setText("queued");
939
+ result.current.thread.composer.send();
940
+ });
941
+ expect(result.current.thread.composer.getState().text).toBe("");
942
+
943
+ act(() => {
944
+ result.current.thread.cancelRun();
945
+ });
946
+ await act(async () => {
947
+ resolveFirstSend();
948
+ });
949
+
950
+ await waitFor(() => {
951
+ expect(result.current.thread.composer.getState().text).toBe("queued");
952
+ });
953
+ expect(send).toHaveBeenCalledTimes(1);
954
+ expect(getText(result.current)).toEqual(["earlier", "earlier answer"]);
955
+ });
956
+
957
+ it("returns the queued send when cancel keeps the trailing message", async () => {
958
+ let resolveFirstSend!: () => void;
959
+ const send = vi
960
+ .fn()
961
+ .mockImplementationOnce(
962
+ () =>
963
+ new Promise<void>((resolve) => {
964
+ resolveFirstSend = resolve;
965
+ }),
966
+ )
967
+ .mockResolvedValue(undefined);
968
+ // Before the turn streams, the dispatched message is the thread's trailing
969
+ // user leaf. Eve owns that message and cannot remove it on cancel.
970
+ const agent = createAgent({
971
+ data: {
972
+ messages: [
973
+ ...settledData.messages,
974
+ { id: "u2", role: "user", parts: [{ type: "text", text: "first" }] },
975
+ ],
976
+ } satisfies EveMessageData,
977
+ status: "submitted",
978
+ send,
979
+ });
980
+ mockUseEveAgent.mockReturnValue(agent as never);
981
+ const { result } = renderHook(() => useEveAgentRuntime());
982
+
983
+ await act(async () => {
984
+ result.current.thread.composer.setText("first");
985
+ result.current.thread.composer.send();
986
+ });
987
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
988
+
989
+ await act(async () => {
990
+ result.current.thread.composer.setText("queued");
991
+ result.current.thread.composer.send();
992
+ });
993
+ act(() => {
994
+ result.current.thread.cancelRun();
995
+ });
996
+ expect(result.current.thread.composer.getState().text).toBe("");
997
+
998
+ await act(async () => {
999
+ resolveFirstSend();
1000
+ });
1001
+
1002
+ await waitFor(() => {
1003
+ expect(result.current.thread.composer.getState().text).toBe("queued");
1004
+ });
1005
+ expect(send).toHaveBeenCalledTimes(1);
1006
+ });
1007
+
1008
+ it("keeps a cancelled tool approval discarded", async () => {
1009
+ let resolveFirstSend!: () => void;
1010
+ const send = vi
1011
+ .fn()
1012
+ .mockImplementationOnce(
1013
+ () =>
1014
+ new Promise<void>((resolve) => {
1015
+ resolveFirstSend = resolve;
1016
+ }),
1017
+ )
1018
+ .mockResolvedValue(undefined);
1019
+ const agent = createAgent({ data: approvalData, send });
1020
+ mockUseEveAgent.mockReturnValue(agent as never);
1021
+ const { result } = renderHook(() => useEveAgentRuntime());
1022
+ const before = getText(result.current);
1023
+
1024
+ await act(async () => {
1025
+ result.current.thread.append({
1026
+ role: "user",
1027
+ content: [{ type: "text", text: "go" }],
1028
+ });
1029
+ });
1030
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
1031
+
1032
+ act(() => {
1033
+ result.current.thread
1034
+ .getMessageByIndex(1)
1035
+ .getMessagePartByToolCallId("call_1")
1036
+ .respondToToolApproval({ optionId: "approve" });
1037
+ });
1038
+ act(() => {
1039
+ result.current.thread.cancelRun();
1040
+ });
1041
+ await act(async () => {
1042
+ resolveFirstSend();
1043
+ });
1044
+
1045
+ expect(send).toHaveBeenCalledTimes(1);
1046
+ expect(result.current.thread.composer.getState().text).toBe("");
1047
+ expect(getText(result.current)).toEqual(before);
1048
+ });
1049
+
861
1050
  it("drops a queued send when the hook unmounts before it dispatches", async () => {
862
1051
  let resolveFirstSend!: () => void;
863
1052
  const send = vi
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
4
4
  import {
5
5
  fromThreadMessageLike,
6
6
  generateId,
7
+ MessageNotSentError,
7
8
  pickExternalStoreSharedOptions,
8
9
  type AppendMessage,
9
10
  type AttachmentAdapter,
@@ -37,10 +38,46 @@ const USER_STAGED_STATUS = {
37
38
  reason: "unknown",
38
39
  } as const;
39
40
 
40
- const sendCancelledError = new Error(
41
+ const sendCancelledError = new MessageNotSentError(
41
42
  "eve send was dropped because the run was cancelled.",
42
43
  );
43
44
 
45
+ const sendAbandonedError = new Error(
46
+ "eve send was dropped because the runtime unmounted.",
47
+ );
48
+
49
+ const isDroppedSend = (error: unknown) =>
50
+ error === sendCancelledError || error === sendAbandonedError;
51
+
52
+ type EveLifecycleCallbackName =
53
+ | "onError"
54
+ | "onEvent"
55
+ | "onFinish"
56
+ | "onSessionChange";
57
+
58
+ const reportEveLifecycleCallbackError = (
59
+ name: EveLifecycleCallbackName,
60
+ error: unknown,
61
+ ) => {
62
+ console.error(`[assistant-ui/eve] ${name} callback threw an error`, error);
63
+ };
64
+
65
+ const invokeEveLifecycleCallback = <T>(
66
+ name: EveLifecycleCallbackName,
67
+ callback: ((value: T) => unknown) | undefined,
68
+ value: T,
69
+ ) => {
70
+ if (!callback) return;
71
+
72
+ try {
73
+ void Promise.resolve(callback(value)).catch((error) => {
74
+ reportEveLifecycleCallbackError(name, error);
75
+ });
76
+ } catch (error) {
77
+ reportEveLifecycleCallbackError(name, error);
78
+ }
79
+ };
80
+
44
81
  const hasRunConfig = (
45
82
  runConfig: AppendMessage["runConfig"],
46
83
  ): runConfig is NonNullable<AppendMessage["runConfig"]> =>
@@ -100,14 +137,36 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
100
137
  ? true
101
138
  : never;
102
139
 
103
- const { onFinish } = agentOptions;
140
+ const { onError, onEvent, onFinish, onSessionChange } = agentOptions;
104
141
  const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);
105
142
  const agent = useEveAgent({
106
143
  ...agentOptions,
144
+ ...(onError
145
+ ? {
146
+ onError: (error) =>
147
+ invokeEveLifecycleCallback("onError", onError, error),
148
+ }
149
+ : {}),
150
+ ...(onEvent
151
+ ? {
152
+ onEvent: (event) =>
153
+ invokeEveLifecycleCallback("onEvent", onEvent, event),
154
+ }
155
+ : {}),
107
156
  onFinish: (snapshot) => {
108
157
  lastFinishStatusRef.current = snapshot.status;
109
- onFinish?.(snapshot);
158
+ invokeEveLifecycleCallback("onFinish", onFinish, snapshot);
110
159
  },
160
+ ...(onSessionChange
161
+ ? {
162
+ onSessionChange: (session) =>
163
+ invokeEveLifecycleCallback(
164
+ "onSessionChange",
165
+ onSessionChange,
166
+ session,
167
+ ),
168
+ }
169
+ : {}),
111
170
  });
112
171
  const runtimeAdapters = useRuntimeAdapters();
113
172
  const [toolStatuses, setToolStatuses] = useState<
@@ -165,11 +224,13 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
165
224
  // status.
166
225
  const sendChainRef = useRef<Promise<void>>(Promise.resolve());
167
226
  const sendEpochRef = useRef(0);
227
+ const isMountedRef = useRef(true);
168
228
 
169
229
  const enqueueSend = (payload: Parameters<typeof agent.send>[0]) => {
170
230
  const epoch = sendEpochRef.current;
171
231
  const next = sendChainRef.current.then(() => {
172
- if (epoch !== sendEpochRef.current) throw sendCancelledError;
232
+ if (epoch !== sendEpochRef.current)
233
+ throw isMountedRef.current ? sendCancelledError : sendAbandonedError;
173
234
  return agent.send(payload);
174
235
  });
175
236
  sendChainRef.current = next.catch(() => {});
@@ -177,9 +238,13 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
177
238
  };
178
239
 
179
240
  // The store outlives the component (useEveAgent holds it in a ref with no
180
- // cleanup), so queued sends must not fire server turns after unmount.
241
+ // cleanup), so queued sends must not fire server turns after unmount. The
242
+ // flag separates that teardown from a user cancel, and is re-armed in setup
243
+ // for a remounted tree.
181
244
  useEffect(() => {
245
+ isMountedRef.current = true;
182
246
  return () => {
247
+ isMountedRef.current = false;
183
248
  sendEpochRef.current += 1;
184
249
  };
185
250
  }, []);
@@ -249,7 +314,11 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
249
314
  ...toEveClientContext(message.runConfig),
250
315
  });
251
316
  } catch (error) {
252
- if (error !== sendCancelledError) throw error;
317
+ // A cancelled send never reached the session, so it rethrows for the
318
+ // composer to take the draft back; an unmounted one has no composer
319
+ // left to restore.
320
+ if (error === sendAbandonedError) return;
321
+ throw error;
253
322
  }
254
323
  },
255
324
  ...(stagedMessages
@@ -286,7 +355,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
286
355
  stagedInputsRef.current.set(stagedMessage.id, input);
287
356
  messagesRef.current = previousMessages;
288
357
  setStagedMessages(previousMessages);
289
- if (error === sendCancelledError) return;
358
+ if (isDroppedSend(error)) return;
290
359
  throw error;
291
360
  }
292
361
  if (lastFinishStatusRef.current === "error") return;
@@ -303,7 +372,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
303
372
  try {
304
373
  await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
305
374
  } catch (error) {
306
- if (error !== sendCancelledError) throw error;
375
+ if (!isDroppedSend(error)) throw error;
307
376
  }
308
377
  },
309
378
  });