@assistant-ui/eve 0.0.9 → 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 AgentbaseAI Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1 +1 @@
1
- {"version":3,"file":"useEveAgentRuntime.d.ts","names":[],"sources":["../src/useEveAgentRuntime.ts"],"mappings":";;;KA4DY,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":";;;KAiEY,4BAA4B,KACtC,mBAAmB,8BAGnB;WACW;aAEM,cAAc;aACd,SAAS;aACT,YAAY;aACZ,QAAQ;aACR,WAAW;;;;;;;;;;cAYjB,qBAAsB,UAAS,2DAA8B"}
@@ -9,6 +9,7 @@ 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
13
  const hasRunConfig = (runConfig) => runConfig?.custom !== void 0 && Object.keys(runConfig.custom).length > 0;
13
14
  /**
14
15
  * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own
@@ -26,7 +27,15 @@ const toEveClientContext = (runConfig) => hasRunConfig(runConfig) ? { clientCont
26
27
  */
27
28
  const useEveAgentRuntime = (options = {}) => {
28
29
  const { adapters, isDisabled: _isDisabled, isSendDisabled: _isSendDisabled, suggestions: _suggestions, unstable_capabilities: _unstable_capabilities, ...agentOptions } = options;
29
- const agent = useEveAgent(agentOptions);
30
+ const { onFinish } = agentOptions;
31
+ const lastFinishStatusRef = useRef(null);
32
+ const agent = useEveAgent({
33
+ ...agentOptions,
34
+ onFinish: (snapshot) => {
35
+ lastFinishStatusRef.current = snapshot.status;
36
+ onFinish?.(snapshot);
37
+ }
38
+ });
30
39
  const runtimeAdapters = useRuntimeAdapters();
31
40
  const [toolStatuses, setToolStatuses] = useState({});
32
41
  const [stagedMessages, setStagedMessages] = useState(null);
@@ -57,6 +66,22 @@ const useEveAgentRuntime = (options = {}) => {
57
66
  const messages = stagedMessages ?? convertedMessages;
58
67
  const messagesRef = useRef(messages);
59
68
  messagesRef.current = messages;
69
+ const sendChainRef = useRef(Promise.resolve());
70
+ const sendEpochRef = useRef(0);
71
+ const enqueueSend = (payload) => {
72
+ const epoch = sendEpochRef.current;
73
+ const next = sendChainRef.current.then(() => {
74
+ if (epoch !== sendEpochRef.current) throw sendCancelledError;
75
+ return agent.send(payload);
76
+ });
77
+ sendChainRef.current = next.catch(() => {});
78
+ return next;
79
+ };
80
+ useEffect(() => {
81
+ return () => {
82
+ sendEpochRef.current += 1;
83
+ };
84
+ }, []);
60
85
  useEffect(() => {
61
86
  if (stagedInputsRef.current.size === 0) return;
62
87
  const baseIds = new Set(convertedMessages.map((message) => message.id));
@@ -104,15 +129,21 @@ const useEveAgentRuntime = (options = {}) => {
104
129
  stageUserMessage(message);
105
130
  return;
106
131
  }
107
- await agent.send({
108
- message: getEveMessageContent(message),
109
- ...toEveClientContext(message.runConfig)
110
- });
132
+ try {
133
+ await enqueueSend({
134
+ message: getEveMessageContent(message),
135
+ ...toEveClientContext(message.runConfig)
136
+ });
137
+ } catch (error) {
138
+ if (error !== sendCancelledError) throw error;
139
+ }
111
140
  },
112
141
  ...stagedMessages ? { onReload: async (parentId, config) => {
113
142
  const stagedRun = getStagedRun(parentId);
114
143
  if (!stagedRun) throw new Error("Runtime does not support reloading messages.");
144
+ const epoch = sendEpochRef.current;
115
145
  for (const { message: stagedMessage, input } of stagedRun) {
146
+ if (epoch !== sendEpochRef.current) return;
116
147
  const previousMessages = messagesRef.current;
117
148
  stagedInputsRef.current.delete(stagedMessage.id);
118
149
  const nextMessages = previousMessages.filter((message) => message.id !== stagedMessage.id);
@@ -120,7 +151,7 @@ const useEveAgentRuntime = (options = {}) => {
120
151
  setStagedMessages(stagedInputsRef.current.size > 0 ? nextMessages : null);
121
152
  const runConfig = stagedMessage.id === parentId && hasRunConfig(config.runConfig) ? config.runConfig : input.runConfig;
122
153
  try {
123
- await agent.send({
154
+ await enqueueSend({
124
155
  message: getEveMessageContent(input.message),
125
156
  ...toEveClientContext(runConfig)
126
157
  });
@@ -128,16 +159,23 @@ const useEveAgentRuntime = (options = {}) => {
128
159
  stagedInputsRef.current.set(stagedMessage.id, input);
129
160
  messagesRef.current = previousMessages;
130
161
  setStagedMessages(previousMessages);
162
+ if (error === sendCancelledError) return;
131
163
  throw error;
132
164
  }
165
+ if (lastFinishStatusRef.current === "error") return;
133
166
  }
134
167
  } } : {},
135
168
  onCancel: () => {
169
+ sendEpochRef.current += 1;
136
170
  agent.stop();
137
171
  return Promise.resolve();
138
172
  },
139
173
  onRespondToToolApproval: async (response) => {
140
- await agent.send({ inputResponses: [toEveInputResponse(response)] });
174
+ try {
175
+ await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
176
+ } catch (error) {
177
+ if (error !== sendCancelledError) throw error;
178
+ }
141
179
  }
142
180
  });
143
181
  };
@@ -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} 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 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 agent = useEveAgent(agentOptions);\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 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 await agent.send({\n message: getEveMessageContent(message),\n ...toEveClientContext(message.runConfig),\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 for (const { message: stagedMessage, input } of stagedRun) {\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 agent.send({\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 throw error;\n }\n }\n },\n }\n : {}),\n onCancel: () => {\n agent.stop();\n return Promise.resolve();\n },\n onRespondToToolApproval: async (response) => {\n await agent.send({ inputResponses: [toEveInputResponse(response)] });\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 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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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/eve",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "description": "Eve runtime adapter for assistant-ui",
5
5
  "keywords": [
6
6
  "eve",
@@ -27,13 +27,8 @@
27
27
  "README.md"
28
28
  ],
29
29
  "sideEffects": false,
30
- "scripts": {
31
- "build": "aui-build",
32
- "test": "vitest run",
33
- "test:watch": "vitest"
34
- },
35
30
  "dependencies": {
36
- "@assistant-ui/core": "^0.3.7"
31
+ "@assistant-ui/core": "^0.3.8"
37
32
  },
38
33
  "peerDependencies": {
39
34
  "@types/react": "*",
@@ -46,7 +41,6 @@
46
41
  }
47
42
  },
48
43
  "devDependencies": {
49
- "@assistant-ui/x-buildutils": "workspace:*",
50
44
  "@testing-library/dom": "^10.4.1",
51
45
  "@testing-library/react": "^16.3.2",
52
46
  "@types/react": "^19.2.17",
@@ -54,7 +48,8 @@
54
48
  "jsdom": "^29.1.1",
55
49
  "react": "^19.2.8",
56
50
  "react-dom": "^19.2.8",
57
- "vitest": "^4.1.10"
51
+ "vitest": "^4.1.10",
52
+ "@assistant-ui/x-buildutils": "0.0.21"
58
53
  },
59
54
  "publishConfig": {
60
55
  "access": "public",
@@ -68,5 +63,10 @@
68
63
  },
69
64
  "bugs": {
70
65
  "url": "https://github.com/assistant-ui/assistant-ui/issues"
66
+ },
67
+ "scripts": {
68
+ "build": "aui-build",
69
+ "test": "vitest run",
70
+ "test:watch": "vitest"
71
71
  }
72
- }
72
+ }
@@ -601,6 +601,88 @@ describe("useEveAgentRuntime staged messages", () => {
601
601
  });
602
602
  });
603
603
 
604
+ it("stops promoting once a promoted turn parks with an error status", async () => {
605
+ let capturedOptions: {
606
+ onFinish?: (snapshot: { status: string }) => void;
607
+ } = {};
608
+ const send = vi.fn().mockImplementation(async () => {
609
+ capturedOptions.onFinish?.({ status: "error" });
610
+ });
611
+ const agent = createAgent({ data: settledData, send });
612
+ mockUseEveAgent.mockImplementation((options) => {
613
+ capturedOptions = options as typeof capturedOptions;
614
+ return agent as never;
615
+ });
616
+ const { result } = renderHook(() => useEveAgentRuntime());
617
+
618
+ await stageMessage(result.current, "first staged");
619
+ await stageMessage(result.current, "second staged");
620
+
621
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
622
+ await act(async () => {
623
+ await result.current.thread.startRun({
624
+ parentId: secondStagedId,
625
+ sourceId: null,
626
+ runConfig: {},
627
+ });
628
+ });
629
+
630
+ expect(send).toHaveBeenCalledTimes(1);
631
+ expect(send).toHaveBeenCalledWith({ message: "first staged" });
632
+ await waitFor(() => {
633
+ expect(getText(result.current)).toEqual([
634
+ "earlier",
635
+ "earlier answer",
636
+ "second staged",
637
+ ]);
638
+ });
639
+ });
640
+
641
+ it("stops promoting when the run is cancelled mid-prefix", async () => {
642
+ let resolveFirstSend!: () => void;
643
+ const send = vi
644
+ .fn()
645
+ .mockImplementationOnce(
646
+ () =>
647
+ new Promise<void>((resolve) => {
648
+ resolveFirstSend = resolve;
649
+ }),
650
+ )
651
+ .mockResolvedValue(undefined);
652
+ const agent = createAgent({ data: settledData, send });
653
+ mockUseEveAgent.mockReturnValue(agent as never);
654
+ const { result } = renderHook(() => useEveAgentRuntime());
655
+
656
+ await stageMessage(result.current, "first staged");
657
+ await stageMessage(result.current, "second staged");
658
+
659
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
660
+ act(() => {
661
+ void result.current.thread.startRun({
662
+ parentId: secondStagedId,
663
+ sourceId: null,
664
+ runConfig: {},
665
+ });
666
+ });
667
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
668
+
669
+ act(() => {
670
+ result.current.thread.cancelRun();
671
+ });
672
+ await act(async () => {
673
+ resolveFirstSend();
674
+ });
675
+
676
+ expect(send).toHaveBeenCalledTimes(1);
677
+ await waitFor(() => {
678
+ expect(getText(result.current)).toEqual([
679
+ "earlier",
680
+ "earlier answer",
681
+ "second staged",
682
+ ]);
683
+ });
684
+ });
685
+
604
686
  it("still rejects reloading a non-staged message", async () => {
605
687
  const agent = createAgent({ data: settledData });
606
688
  mockUseEveAgent.mockReturnValue(agent as never);
@@ -620,3 +702,261 @@ describe("useEveAgentRuntime staged messages", () => {
620
702
  expect(agent.send).not.toHaveBeenCalled();
621
703
  });
622
704
  });
705
+
706
+ const approvalData: EveMessageData = {
707
+ messages: [
708
+ { id: "u1", role: "user", parts: [{ type: "text", text: "send it" }] },
709
+ {
710
+ id: "a1",
711
+ role: "assistant",
712
+ parts: [
713
+ {
714
+ type: "dynamic-tool",
715
+ state: "approval-requested",
716
+ toolCallId: "call_1",
717
+ toolName: "send_email",
718
+ input: { to: "dev@example.com" },
719
+ approval: { id: "req_1" },
720
+ toolMetadata: {
721
+ eve: {
722
+ kind: "tool-call",
723
+ name: "send_email",
724
+ inputRequest: {
725
+ requestId: "req_1",
726
+ prompt: "Send the email?",
727
+ display: "confirmation",
728
+ options: [
729
+ { id: "approve", label: "Approve" },
730
+ { id: "deny", label: "Deny" },
731
+ ],
732
+ },
733
+ },
734
+ },
735
+ },
736
+ ],
737
+ },
738
+ ],
739
+ };
740
+
741
+ describe("useEveAgentRuntime concurrent sends", () => {
742
+ it("defers an approval clicked while a turn is in flight until the turn parks", async () => {
743
+ let resolveFirstSend!: () => void;
744
+ const send = vi
745
+ .fn()
746
+ .mockImplementationOnce(
747
+ () =>
748
+ new Promise<void>((resolve) => {
749
+ resolveFirstSend = resolve;
750
+ }),
751
+ )
752
+ .mockResolvedValue(undefined);
753
+ const agent = createAgent({ data: approvalData, send });
754
+ mockUseEveAgent.mockReturnValue(agent as never);
755
+ const { result } = renderHook(() => useEveAgentRuntime());
756
+
757
+ await act(async () => {
758
+ result.current.thread.append({
759
+ role: "user",
760
+ content: [{ type: "text", text: "go" }],
761
+ });
762
+ });
763
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
764
+
765
+ act(() => {
766
+ result.current.thread
767
+ .getMessageByIndex(1)
768
+ .getMessagePartByToolCallId("call_1")
769
+ .respondToToolApproval({ optionId: "approve" });
770
+ });
771
+ expect(send).toHaveBeenCalledTimes(1);
772
+
773
+ await act(async () => {
774
+ resolveFirstSend();
775
+ });
776
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
777
+ expect(send).toHaveBeenNthCalledWith(2, {
778
+ inputResponses: [{ requestId: "req_1", optionId: "approve" }],
779
+ });
780
+ });
781
+
782
+ it("queues a send issued while a turn is in flight and preserves order", async () => {
783
+ let resolveFirstSend!: () => void;
784
+ const send = vi
785
+ .fn()
786
+ .mockImplementationOnce(
787
+ () =>
788
+ new Promise<void>((resolve) => {
789
+ resolveFirstSend = resolve;
790
+ }),
791
+ )
792
+ .mockResolvedValue(undefined);
793
+ const agent = createAgent({ data: settledData, send });
794
+ mockUseEveAgent.mockReturnValue(agent as never);
795
+ const { result } = renderHook(() => useEveAgentRuntime());
796
+
797
+ await act(async () => {
798
+ result.current.thread.append({
799
+ role: "user",
800
+ content: [{ type: "text", text: "first" }],
801
+ });
802
+ });
803
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
804
+
805
+ await act(async () => {
806
+ result.current.thread.append({
807
+ role: "user",
808
+ content: [{ type: "text", text: "second" }],
809
+ });
810
+ });
811
+ expect(send).toHaveBeenCalledTimes(1);
812
+
813
+ await act(async () => {
814
+ resolveFirstSend();
815
+ });
816
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(2));
817
+ expect(send).toHaveBeenNthCalledWith(1, { message: "first" });
818
+ expect(send).toHaveBeenNthCalledWith(2, { message: "second" });
819
+ });
820
+
821
+ it("drops a queued send when the run is cancelled before it dispatches", async () => {
822
+ let resolveFirstSend!: () => void;
823
+ const send = vi
824
+ .fn()
825
+ .mockImplementationOnce(
826
+ () =>
827
+ new Promise<void>((resolve) => {
828
+ resolveFirstSend = resolve;
829
+ }),
830
+ )
831
+ .mockResolvedValue(undefined);
832
+ const agent = createAgent({ data: settledData, send });
833
+ mockUseEveAgent.mockReturnValue(agent as never);
834
+ const { result } = renderHook(() => useEveAgentRuntime());
835
+
836
+ await act(async () => {
837
+ result.current.thread.append({
838
+ role: "user",
839
+ content: [{ type: "text", text: "first" }],
840
+ });
841
+ });
842
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
843
+
844
+ await act(async () => {
845
+ result.current.thread.append({
846
+ role: "user",
847
+ content: [{ type: "text", text: "queued" }],
848
+ });
849
+ });
850
+ act(() => {
851
+ result.current.thread.cancelRun();
852
+ });
853
+ expect(agent.stop).toHaveBeenCalledTimes(1);
854
+
855
+ await act(async () => {
856
+ resolveFirstSend();
857
+ });
858
+ expect(send).toHaveBeenCalledTimes(1);
859
+ });
860
+
861
+ it("drops a queued send when the hook unmounts before it dispatches", async () => {
862
+ let resolveFirstSend!: () => void;
863
+ const send = vi
864
+ .fn()
865
+ .mockImplementationOnce(
866
+ () =>
867
+ new Promise<void>((resolve) => {
868
+ resolveFirstSend = resolve;
869
+ }),
870
+ )
871
+ .mockResolvedValue(undefined);
872
+ const agent = createAgent({ data: settledData, send });
873
+ mockUseEveAgent.mockReturnValue(agent as never);
874
+ const { result, unmount } = renderHook(() => useEveAgentRuntime());
875
+
876
+ await act(async () => {
877
+ result.current.thread.append({
878
+ role: "user",
879
+ content: [{ type: "text", text: "first" }],
880
+ });
881
+ });
882
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
883
+
884
+ await act(async () => {
885
+ result.current.thread.append({
886
+ role: "user",
887
+ content: [{ type: "text", text: "queued" }],
888
+ });
889
+ });
890
+ unmount();
891
+
892
+ resolveFirstSend();
893
+ await Promise.resolve();
894
+ await Promise.resolve();
895
+ expect(send).toHaveBeenCalledTimes(1);
896
+ });
897
+
898
+ it("forwards onFinish snapshots to the caller's onFinish", () => {
899
+ let capturedOptions: {
900
+ onFinish?: (snapshot: { status: string }) => void;
901
+ } = {};
902
+ const agent = createAgent({ data: settledData });
903
+ mockUseEveAgent.mockImplementation((options) => {
904
+ capturedOptions = options as typeof capturedOptions;
905
+ return agent as never;
906
+ });
907
+ const onFinish = vi.fn();
908
+ renderHook(() => useEveAgentRuntime({ onFinish }));
909
+
910
+ capturedOptions.onFinish?.({ status: "ready" });
911
+
912
+ expect(onFinish).toHaveBeenCalledWith({ status: "ready" });
913
+ });
914
+
915
+ it("orders a send issued mid-promotion after the in-flight staged draft", async () => {
916
+ let resolveFirstSend!: () => void;
917
+ const send = vi
918
+ .fn()
919
+ .mockImplementationOnce(
920
+ () =>
921
+ new Promise<void>((resolve) => {
922
+ resolveFirstSend = resolve;
923
+ }),
924
+ )
925
+ .mockResolvedValue(undefined);
926
+ const agent = createAgent({ data: settledData, send });
927
+ mockUseEveAgent.mockReturnValue(agent as never);
928
+ const { result } = renderHook(() => useEveAgentRuntime());
929
+
930
+ await stageMessage(result.current, "first staged");
931
+ await stageMessage(result.current, "second staged");
932
+
933
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
934
+ act(() => {
935
+ void result.current.thread.startRun({
936
+ parentId: secondStagedId,
937
+ sourceId: null,
938
+ runConfig: {},
939
+ });
940
+ });
941
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
942
+
943
+ await act(async () => {
944
+ result.current.thread.append({
945
+ role: "user",
946
+ content: [{ type: "text", text: "interleaved" }],
947
+ });
948
+ });
949
+ expect(send).toHaveBeenCalledTimes(1);
950
+
951
+ await act(async () => {
952
+ resolveFirstSend();
953
+ });
954
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(3));
955
+
956
+ expect(send.mock.calls.map(([payload]) => payload)).toEqual([
957
+ { message: "first staged" },
958
+ { message: "interleaved" },
959
+ { message: "second staged" },
960
+ ]);
961
+ });
962
+ });
@@ -23,6 +23,7 @@ import {
23
23
  useEveAgent,
24
24
  type EveMessageData,
25
25
  type UseEveAgentOptions,
26
+ type UseEveAgentStatus,
26
27
  } from "eve/react";
27
28
  import type { SendTurnPayload } from "eve/client";
28
29
  import {
@@ -36,6 +37,10 @@ const USER_STAGED_STATUS = {
36
37
  reason: "unknown",
37
38
  } as const;
38
39
 
40
+ const sendCancelledError = new Error(
41
+ "eve send was dropped because the run was cancelled.",
42
+ );
43
+
39
44
  const hasRunConfig = (
40
45
  runConfig: AppendMessage["runConfig"],
41
46
  ): runConfig is NonNullable<AppendMessage["runConfig"]> =>
@@ -95,7 +100,15 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
95
100
  ? true
96
101
  : never;
97
102
 
98
- const agent = useEveAgent(agentOptions);
103
+ const { onFinish } = agentOptions;
104
+ const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);
105
+ const agent = useEveAgent({
106
+ ...agentOptions,
107
+ onFinish: (snapshot) => {
108
+ lastFinishStatusRef.current = snapshot.status;
109
+ onFinish?.(snapshot);
110
+ },
111
+ });
99
112
  const runtimeAdapters = useRuntimeAdapters();
100
113
  const [toolStatuses, setToolStatuses] = useState<
101
114
  Record<string, ToolExecutionStatus>
@@ -146,6 +159,31 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
146
159
  const messagesRef = useRef(messages);
147
160
  messagesRef.current = messages;
148
161
 
162
+ // Upstream `EveAgentStore.send` rejects while a turn is in flight and only
163
+ // resolves once the turn's stream parks, so a pending chain link is exactly
164
+ // an active turn; chaining every send serializes them without watching
165
+ // status.
166
+ const sendChainRef = useRef<Promise<void>>(Promise.resolve());
167
+ const sendEpochRef = useRef(0);
168
+
169
+ const enqueueSend = (payload: Parameters<typeof agent.send>[0]) => {
170
+ const epoch = sendEpochRef.current;
171
+ const next = sendChainRef.current.then(() => {
172
+ if (epoch !== sendEpochRef.current) throw sendCancelledError;
173
+ return agent.send(payload);
174
+ });
175
+ sendChainRef.current = next.catch(() => {});
176
+ return next;
177
+ };
178
+
179
+ // 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.
181
+ useEffect(() => {
182
+ return () => {
183
+ sendEpochRef.current += 1;
184
+ };
185
+ }, []);
186
+
149
187
  useEffect(() => {
150
188
  if (stagedInputsRef.current.size === 0) return;
151
189
  const baseIds = new Set(convertedMessages.map((message) => message.id));
@@ -205,10 +243,14 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
205
243
  stageUserMessage(message);
206
244
  return;
207
245
  }
208
- await agent.send({
209
- message: getEveMessageContent(message),
210
- ...toEveClientContext(message.runConfig),
211
- });
246
+ try {
247
+ await enqueueSend({
248
+ message: getEveMessageContent(message),
249
+ ...toEveClientContext(message.runConfig),
250
+ });
251
+ } catch (error) {
252
+ if (error !== sendCancelledError) throw error;
253
+ }
212
254
  },
213
255
  ...(stagedMessages
214
256
  ? {
@@ -216,7 +258,9 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
216
258
  const stagedRun = getStagedRun(parentId);
217
259
  if (!stagedRun)
218
260
  throw new Error("Runtime does not support reloading messages.");
261
+ const epoch = sendEpochRef.current;
219
262
  for (const { message: stagedMessage, input } of stagedRun) {
263
+ if (epoch !== sendEpochRef.current) return;
220
264
  const previousMessages = messagesRef.current;
221
265
  stagedInputsRef.current.delete(stagedMessage.id);
222
266
  const nextMessages = previousMessages.filter(
@@ -234,7 +278,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
234
278
  ? config.runConfig
235
279
  : input.runConfig;
236
280
  try {
237
- await agent.send({
281
+ await enqueueSend({
238
282
  message: getEveMessageContent(input.message),
239
283
  ...toEveClientContext(runConfig),
240
284
  });
@@ -242,18 +286,25 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
242
286
  stagedInputsRef.current.set(stagedMessage.id, input);
243
287
  messagesRef.current = previousMessages;
244
288
  setStagedMessages(previousMessages);
289
+ if (error === sendCancelledError) return;
245
290
  throw error;
246
291
  }
292
+ if (lastFinishStatusRef.current === "error") return;
247
293
  }
248
294
  },
249
295
  }
250
296
  : {}),
251
297
  onCancel: () => {
298
+ sendEpochRef.current += 1;
252
299
  agent.stop();
253
300
  return Promise.resolve();
254
301
  },
255
302
  onRespondToToolApproval: async (response) => {
256
- await agent.send({ inputResponses: [toEveInputResponse(response)] });
303
+ try {
304
+ await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
305
+ } catch (error) {
306
+ if (error !== sendCancelledError) throw error;
307
+ }
257
308
  },
258
309
  });
259
310
  };