@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 +21 -0
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +45 -7
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +10 -10
- package/src/useEveAgentRuntime.test.tsx +340 -0
- package/src/useEveAgentRuntime.ts +58 -7
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":";;;
|
|
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
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
-
|
|
303
|
+
try {
|
|
304
|
+
await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error !== sendCancelledError) throw error;
|
|
307
|
+
}
|
|
257
308
|
},
|
|
258
309
|
});
|
|
259
310
|
};
|