@assistant-ui/eve 0.0.8 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"useEveAgentRuntime.d.ts","names":[],"sources":["../src/useEveAgentRuntime.ts"],"mappings":";;;KAqCY,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,15 @@ 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.");
13
+ const hasRunConfig = (runConfig) => runConfig?.custom !== void 0 && Object.keys(runConfig.custom).length > 0;
14
+ /**
15
+ * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own
16
+ * namespace and serializes it into a model-visible context message, so sending
17
+ * the assistant-ui envelope would surface a literal `"custom"` key in the
18
+ * prompt and to every eve-side handler.
19
+ */
20
+ const toEveClientContext = (runConfig) => hasRunConfig(runConfig) ? { clientContext: runConfig.custom } : {};
12
21
  /**
13
22
  * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.
14
23
  *
@@ -18,7 +27,15 @@ const USER_STAGED_STATUS = {
18
27
  */
19
28
  const useEveAgentRuntime = (options = {}) => {
20
29
  const { adapters, isDisabled: _isDisabled, isSendDisabled: _isSendDisabled, suggestions: _suggestions, unstable_capabilities: _unstable_capabilities, ...agentOptions } = options;
21
- 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
+ });
22
39
  const runtimeAdapters = useRuntimeAdapters();
23
40
  const [toolStatuses, setToolStatuses] = useState({});
24
41
  const [stagedMessages, setStagedMessages] = useState(null);
@@ -49,6 +66,22 @@ const useEveAgentRuntime = (options = {}) => {
49
66
  const messages = stagedMessages ?? convertedMessages;
50
67
  const messagesRef = useRef(messages);
51
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
+ }, []);
52
85
  useEffect(() => {
53
86
  if (stagedInputsRef.current.size === 0) return;
54
87
  const baseIds = new Set(convertedMessages.map((message) => message.id));
@@ -96,33 +129,53 @@ const useEveAgentRuntime = (options = {}) => {
96
129
  stageUserMessage(message);
97
130
  return;
98
131
  }
99
- await agent.send({ message: getEveMessageContent(message) });
132
+ try {
133
+ await enqueueSend({
134
+ message: getEveMessageContent(message),
135
+ ...toEveClientContext(message.runConfig)
136
+ });
137
+ } catch (error) {
138
+ if (error !== sendCancelledError) throw error;
139
+ }
100
140
  },
101
- ...stagedMessages ? { onReload: async (parentId) => {
141
+ ...stagedMessages ? { onReload: async (parentId, config) => {
102
142
  const stagedRun = getStagedRun(parentId);
103
143
  if (!stagedRun) throw new Error("Runtime does not support reloading messages.");
144
+ const epoch = sendEpochRef.current;
104
145
  for (const { message: stagedMessage, input } of stagedRun) {
146
+ if (epoch !== sendEpochRef.current) return;
105
147
  const previousMessages = messagesRef.current;
106
148
  stagedInputsRef.current.delete(stagedMessage.id);
107
149
  const nextMessages = previousMessages.filter((message) => message.id !== stagedMessage.id);
108
150
  messagesRef.current = nextMessages;
109
151
  setStagedMessages(stagedInputsRef.current.size > 0 ? nextMessages : null);
152
+ const runConfig = stagedMessage.id === parentId && hasRunConfig(config.runConfig) ? config.runConfig : input.runConfig;
110
153
  try {
111
- await agent.send({ message: getEveMessageContent(input.message) });
154
+ await enqueueSend({
155
+ message: getEveMessageContent(input.message),
156
+ ...toEveClientContext(runConfig)
157
+ });
112
158
  } catch (error) {
113
159
  stagedInputsRef.current.set(stagedMessage.id, input);
114
160
  messagesRef.current = previousMessages;
115
161
  setStagedMessages(previousMessages);
162
+ if (error === sendCancelledError) return;
116
163
  throw error;
117
164
  }
165
+ if (lastFinishStatusRef.current === "error") return;
118
166
  }
119
167
  } } : {},
120
168
  onCancel: () => {
169
+ sendEpochRef.current += 1;
121
170
  agent.stop();
122
171
  return Promise.resolve();
123
172
  },
124
173
  onRespondToToolApproval: async (response) => {
125
- await agent.send({ inputResponses: [toEveInputResponse(response)] });
174
+ try {
175
+ await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
176
+ } catch (error) {
177
+ if (error !== sendCancelledError) throw error;
178
+ }
126
179
  }
127
180
  });
128
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 {\n convertEveMessages,\n getEveMessageContent,\n toEveInputResponse,\n} from \"./convertEveMessages\";\n\nconst USER_STAGED_STATUS = {\n type: \"complete\",\n reason: \"unknown\",\n} as const;\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({ message: getEveMessageContent(message) });\n },\n ...(stagedMessages\n ? {\n onReload: async (parentId: string | null) => {\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 try {\n await agent.send({\n message: getEveMessageContent(input.message),\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":";;;;;;;AAkCA,MAAM,qBAAqB;CACzB,MAAM;CACN,QAAQ;AACV;AAEA,MAAM,qCAAqB,IAAI,MAC7B,qDACF;AAEA,MAAM,gBACJ,cAEA,WAAW,WAAW,KAAA,KAAa,OAAO,KAAK,UAAU,MAAM,CAAC,CAAC,SAAS;;;;;;;AAQ5E,MAAM,sBACJ,cAEA,aAAa,SAAS,IAClB,EACE,eAAe,UAAU,OAG3B,IACA,CAAC;;;;;;;;AAyBP,MAAa,sBAAsB,UAAqC,CAAC,MAAM;CAC7E,MAAM,EACJ,UACA,YAAY,aACZ,gBAAgB,iBAChB,aAAa,cACb,uBAAuB,wBACvB,GAAG,iBACD;CAMJ,MAAM,EAAE,aAAa;CACrB,MAAM,sBAAsB,OAAiC,IAAI;CACjE,MAAM,QAAQ,YAAY;EACxB,GAAG;EACH,WAAW,aAAa;GACtB,oBAAoB,UAAU,SAAS;GACvC,WAAW,QAAQ;EACrB;CACF,CAAC;CACD,MAAM,kBAAkB,mBAAmB;CAC3C,MAAM,CAAC,cAAc,mBAAmB,SAEtC,CAAC,CAAC;CACJ,MAAM,CAAC,gBAAgB,qBAAqB,SAC1C,IACF;CACA,MAAM,0BAA0B,uBAAO,IAAI,IAAkB,CAAC;CAC9D,MAAM,kBAAkB,uBACtB,IAAI,IAGF,CACJ;CAEA,MAAM,oBAAoB,OAAO,OAAO,YAAY,CAAC,CAAC,MACnD,WAAW,QAAQ,SAAS,WAC/B;CACA,MAAM,YACJ,MAAM,WAAW,eACjB,MAAM,WAAW,eACjB;CAEF,MAAM,oBAAoB,cAAc;EACtC,MAAM,uBAAuB,wBAAwB;EACrD,MAAM,aAAa,IAAI,IACrB,MAAM,KAAK,SAAS,KAAK,YAAY,QAAQ,EAAE,CACjD;EACA,KAAK,MAAM,aAAa,qBAAqB,KAAK,GAChD,IAAI,CAAC,WAAW,IAAI,SAAS,GAAG,qBAAqB,OAAO,SAAS;EAGvE,OAAO,mBAAmB,MAAM,MAAM;GACpC;GACA,OAAO,MAAM;GACb,eAAe,YAAY;IACzB,MAAM,WAAW,qBAAqB,IAAI,QAAQ,EAAE;IACpD,IAAI,UAAU,OAAO;IAErB,MAAM,4BAAY,IAAI,KAAK;IAC3B,qBAAqB,IAAI,QAAQ,IAAI,SAAS;IAC9C,OAAO;GACT;EACF,CAAC;CACH,GAAG;EAAC,MAAM;EAAM,MAAM;EAAO;CAAS,CAAC;CAEvC,MAAM,WAAW,kBAAkB;CACnC,MAAM,cAAc,OAAO,QAAQ;CACnC,YAAY,UAAU;CAMtB,MAAM,eAAe,OAAsB,QAAQ,QAAQ,CAAC;CAC5D,MAAM,eAAe,OAAO,CAAC;CAE7B,MAAM,eAAe,YAA8C;EACjE,MAAM,QAAQ,aAAa;EAC3B,MAAM,OAAO,aAAa,QAAQ,WAAW;GAC3C,IAAI,UAAU,aAAa,SAAS,MAAM;GAC1C,OAAO,MAAM,KAAK,OAAO;EAC3B,CAAC;EACD,aAAa,UAAU,KAAK,YAAY,CAAC,CAAC;EAC1C,OAAO;CACT;CAIA,gBAAgB;EACd,aAAa;GACX,aAAa,WAAW;EAC1B;CACF,GAAG,CAAC,CAAC;CAEL,gBAAgB;EACd,IAAI,gBAAgB,QAAQ,SAAS,GAAG;EACxC,MAAM,UAAU,IAAI,IAAI,kBAAkB,KAAK,YAAY,QAAQ,EAAE,CAAC;EACtE,MAAM,YAAY,YAAY,QAAQ,QACnC,YACC,gBAAgB,QAAQ,IAAI,QAAQ,EAAE,KAAK,CAAC,QAAQ,IAAI,QAAQ,EAAE,CACtE;EACA,kBACE,UAAU,SAAS,IAAI,CAAC,GAAG,mBAAmB,GAAG,SAAS,IAAI,IAChE;CACF,GAAG,CAAC,iBAAiB,CAAC;CAEtB,MAAM,gBAAgB,aAA4B;EAChD,IAAI,CAAC,YAAY,CAAC,gBAAgB,QAAQ,IAAI,QAAQ,GAAG,OAAO;EAChE,MAAM,SAGA,CAAC;EACP,KAAK,MAAM,WAAW,YAAY,SAAS;GACzC,MAAM,QAAQ,gBAAgB,QAAQ,IAAI,QAAQ,EAAE;GACpD,IAAI,OAAO,OAAO,KAAK;IAAE;IAAS;GAAM,CAAC;GACzC,IAAI,QAAQ,OAAO,UAAU;EAC/B;EACA,OAAO;CACT;CAEA,MAAM,oBAAoB,YAA2B;EACnD,MAAM,gBAAgB,sBACpB,SACA,WAAW,GACX,kBACF;EACA,gBAAgB,QAAQ,IAAI,cAAc,IAAI;GAC5C;GACA,WAAW,QAAQ;EACrB,CAAC;EACD,MAAM,eAAe,CAAC,GAAG,YAAY,SAAS,aAAa;EAC3D,YAAY,UAAU;EACtB,kBAAkB,YAAY;CAChC;CAEA,OAAO,wBAAwB;EAC7B,GAAG,+BAA+B,OAAO;EACzC;EACA;EACA,gCAAgC;EAChC;EACA,UAAU;GACR,aAAa,UAAU,eAAe,iBAAiB;GACvD,QAAQ,UAAU;GAClB,WAAW,UAAU;GACrB,OAAO,UAAU;GACjB,UAAU,UAAU;EACtB;EACA,OAAO,OAAO,YAAY;GACxB,IAAI,EAAE,QAAQ,YAAY,QAAQ,SAAS,SAAS;IAClD,iBAAiB,OAAO;IACxB;GACF;GACA,IAAI;IACF,MAAM,YAAY;KAChB,SAAS,qBAAqB,OAAO;KACrC,GAAG,mBAAmB,QAAQ,SAAS;IACzC,CAAC;GACH,SAAS,OAAO;IACd,IAAI,UAAU,oBAAoB,MAAM;GAC1C;EACF;EACA,GAAI,iBACA,EACE,UAAU,OAAO,UAAyB,WAAW;GACnD,MAAM,YAAY,aAAa,QAAQ;GACvC,IAAI,CAAC,WACH,MAAM,IAAI,MAAM,8CAA8C;GAChE,MAAM,QAAQ,aAAa;GAC3B,KAAK,MAAM,EAAE,SAAS,eAAe,WAAW,WAAW;IACzD,IAAI,UAAU,aAAa,SAAS;IACpC,MAAM,mBAAmB,YAAY;IACrC,gBAAgB,QAAQ,OAAO,cAAc,EAAE;IAC/C,MAAM,eAAe,iBAAiB,QACnC,YAAY,QAAQ,OAAO,cAAc,EAC5C;IACA,YAAY,UAAU;IACtB,kBACE,gBAAgB,QAAQ,OAAO,IAAI,eAAe,IACpD;IAIA,MAAM,YACJ,cAAc,OAAO,YAAY,aAAa,OAAO,SAAS,IAC1D,OAAO,YACP,MAAM;IACZ,IAAI;KACF,MAAM,YAAY;MAChB,SAAS,qBAAqB,MAAM,OAAO;MAC3C,GAAG,mBAAmB,SAAS;KACjC,CAAC;IACH,SAAS,OAAO;KACd,gBAAgB,QAAQ,IAAI,cAAc,IAAI,KAAK;KACnD,YAAY,UAAU;KACtB,kBAAkB,gBAAgB;KAClC,IAAI,UAAU,oBAAoB;KAClC,MAAM;IACR;IACA,IAAI,oBAAoB,YAAY,SAAS;GAC/C;EACF,EACF,IACA,CAAC;EACL,gBAAgB;GACd,aAAa,WAAW;GACxB,MAAM,KAAK;GACX,OAAO,QAAQ,QAAQ;EACzB;EACA,yBAAyB,OAAO,aAAa;GAC3C,IAAI;IACF,MAAM,YAAY,EAAE,gBAAgB,CAAC,mBAAmB,QAAQ,CAAC,EAAE,CAAC;GACtE,SAAS,OAAO;IACd,IAAI,UAAU,oBAAoB,MAAM;GAC1C;EACF;CACF,CAAC;AACH"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assistant-ui/eve",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
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.6"
31
+ "@assistant-ui/core": "^0.3.8"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@types/react": "*",
@@ -90,6 +90,180 @@ describe("useEveAgentRuntime status forwarding", () => {
90
90
  });
91
91
  });
92
92
 
93
+ it("forwards runConfig to eve as one-turn client context when sending", async () => {
94
+ const agent = createAgent({
95
+ data: { messages: [] } satisfies EveMessageData,
96
+ });
97
+ mockUseEveAgent.mockReturnValue(agent as never);
98
+
99
+ const { result } = renderHook(() => useEveAgentRuntime());
100
+
101
+ act(() => {
102
+ result.current.thread.append({
103
+ role: "user",
104
+ content: [{ type: "text", text: "hello" }],
105
+ runConfig: { custom: { page: "/pricing" } },
106
+ });
107
+ });
108
+
109
+ await waitFor(() => {
110
+ expect(agent.send).toHaveBeenCalledWith({
111
+ message: "hello",
112
+ clientContext: { page: "/pricing" },
113
+ });
114
+ });
115
+ });
116
+
117
+ it("omits clientContext when no runConfig is provided", async () => {
118
+ const agent = createAgent({
119
+ data: { messages: [] } satisfies EveMessageData,
120
+ });
121
+ mockUseEveAgent.mockReturnValue(agent as never);
122
+
123
+ const { result } = renderHook(() => useEveAgentRuntime());
124
+
125
+ act(() => {
126
+ result.current.thread.append({
127
+ role: "user",
128
+ content: [{ type: "text", text: "hello" }],
129
+ });
130
+ });
131
+
132
+ await waitFor(() => {
133
+ expect(agent.send).toHaveBeenCalledWith({ message: "hello" });
134
+ });
135
+ });
136
+
137
+ it("omits clientContext when runConfig.custom is empty", async () => {
138
+ const agent = createAgent({
139
+ data: { messages: [] } satisfies EveMessageData,
140
+ });
141
+ mockUseEveAgent.mockReturnValue(agent as never);
142
+
143
+ const { result } = renderHook(() => useEveAgentRuntime());
144
+
145
+ act(() => {
146
+ result.current.thread.append({
147
+ role: "user",
148
+ content: [{ type: "text", text: "hello" }],
149
+ runConfig: { custom: {} },
150
+ });
151
+ });
152
+
153
+ await waitFor(() => {
154
+ expect(agent.send).toHaveBeenCalledWith({ message: "hello" });
155
+ });
156
+ });
157
+
158
+ it("prefers the reload-time runConfig over the staged one", async () => {
159
+ const agent = createAgent({
160
+ data: { messages: [] } satisfies EveMessageData,
161
+ });
162
+ mockUseEveAgent.mockReturnValue(agent as never);
163
+
164
+ const { result } = renderHook(() => useEveAgentRuntime());
165
+
166
+ act(() => {
167
+ result.current.thread.append({
168
+ role: "user",
169
+ content: [{ type: "text", text: "hello" }],
170
+ startRun: false,
171
+ runConfig: { custom: { page: "/staged" } },
172
+ });
173
+ });
174
+
175
+ await waitFor(() => {
176
+ expect(result.current.thread.getState().messages.length).toBe(1);
177
+ });
178
+ const stagedId = result.current.thread.getState().messages[0]!.id;
179
+
180
+ act(() => {
181
+ result.current.thread.startRun({
182
+ parentId: stagedId,
183
+ runConfig: { custom: { page: "/reload" } },
184
+ });
185
+ });
186
+
187
+ await waitFor(() => {
188
+ expect(agent.send).toHaveBeenCalledWith({
189
+ message: "hello",
190
+ clientContext: { page: "/reload" },
191
+ });
192
+ });
193
+ });
194
+
195
+ it("falls back to the staged runConfig when reload passes none", async () => {
196
+ const agent = createAgent({
197
+ data: { messages: [] } satisfies EveMessageData,
198
+ });
199
+ mockUseEveAgent.mockReturnValue(agent as never);
200
+
201
+ const { result } = renderHook(() => useEveAgentRuntime());
202
+
203
+ act(() => {
204
+ result.current.thread.append({
205
+ role: "user",
206
+ content: [{ type: "text", text: "hello" }],
207
+ startRun: false,
208
+ runConfig: { custom: { page: "/staged" } },
209
+ });
210
+ });
211
+
212
+ await waitFor(() => {
213
+ expect(result.current.thread.getState().messages.length).toBe(1);
214
+ });
215
+ const stagedId = result.current.thread.getState().messages[0]!.id;
216
+
217
+ act(() => {
218
+ result.current.thread.startRun({ parentId: stagedId });
219
+ });
220
+
221
+ await waitFor(() => {
222
+ expect(agent.send).toHaveBeenCalledWith({
223
+ message: "hello",
224
+ clientContext: { page: "/staged" },
225
+ });
226
+ });
227
+ });
228
+
229
+ // Known limitation: core normalizes an omitted reload runConfig to {}
230
+ // (toStartRunConfig in core/src/runtime/api/thread-runtime.ts), so an
231
+ // explicit empty reload config is indistinguishable from an omitted one at
232
+ // the adapter and cannot clear the staged context.
233
+ it("cannot clear the staged runConfig with an explicit empty reload config", async () => {
234
+ const agent = createAgent({
235
+ data: { messages: [] } satisfies EveMessageData,
236
+ });
237
+ mockUseEveAgent.mockReturnValue(agent as never);
238
+
239
+ const { result } = renderHook(() => useEveAgentRuntime());
240
+
241
+ act(() => {
242
+ result.current.thread.append({
243
+ role: "user",
244
+ content: [{ type: "text", text: "hello" }],
245
+ startRun: false,
246
+ runConfig: { custom: { page: "/staged" } },
247
+ });
248
+ });
249
+
250
+ await waitFor(() => {
251
+ expect(result.current.thread.getState().messages.length).toBe(1);
252
+ });
253
+ const stagedId = result.current.thread.getState().messages[0]!.id;
254
+
255
+ act(() => {
256
+ result.current.thread.startRun({ parentId: stagedId, runConfig: {} });
257
+ });
258
+
259
+ await waitFor(() => {
260
+ expect(agent.send).toHaveBeenCalledWith({
261
+ message: "hello",
262
+ clientContext: { page: "/staged" },
263
+ });
264
+ });
265
+ });
266
+
93
267
  it("keeps the last assistant message running while streaming", () => {
94
268
  mockUseEveAgent.mockReturnValue(
95
269
  createAgent({ status: "streaming" }) as never,
@@ -284,6 +458,46 @@ describe("useEveAgentRuntime staged messages", () => {
284
458
  });
285
459
  });
286
460
 
461
+ it("applies the reload runConfig only to the message being reloaded", async () => {
462
+ const agent = createAgent({ data: settledData });
463
+ mockUseEveAgent.mockReturnValue(agent as never);
464
+ const { result } = renderHook(() => useEveAgentRuntime());
465
+
466
+ const stageWithConfig = async (text: string, page: string) => {
467
+ await act(async () => {
468
+ result.current.thread.append({
469
+ role: "user",
470
+ content: [{ type: "text", text }],
471
+ startRun: false,
472
+ runConfig: { custom: { page } },
473
+ });
474
+ });
475
+ };
476
+
477
+ await stageWithConfig("first staged", "/first");
478
+ await stageWithConfig("second staged", "/second");
479
+
480
+ const secondStagedId = result.current.thread.getState().messages[3]!.id;
481
+ await act(async () => {
482
+ await result.current.thread.startRun({
483
+ parentId: secondStagedId,
484
+ sourceId: null,
485
+ runConfig: { custom: { page: "/reloaded" } },
486
+ });
487
+ });
488
+
489
+ // the earlier draft keeps the context it was staged with; only the
490
+ // reloaded message takes the reload-time config
491
+ expect(agent.send).toHaveBeenNthCalledWith(1, {
492
+ message: "first staged",
493
+ clientContext: { page: "/first" },
494
+ });
495
+ expect(agent.send).toHaveBeenNthCalledWith(2, {
496
+ message: "second staged",
497
+ clientContext: { page: "/reloaded" },
498
+ });
499
+ });
500
+
287
501
  it("keeps later staged messages visible after promoting one staged parent", async () => {
288
502
  const agent = createAgent({ data: settledData });
289
503
  mockUseEveAgent.mockReturnValue(agent as never);
@@ -387,6 +601,88 @@ describe("useEveAgentRuntime staged messages", () => {
387
601
  });
388
602
  });
389
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
+
390
686
  it("still rejects reloading a non-staged message", async () => {
391
687
  const agent = createAgent({ data: settledData });
392
688
  mockUseEveAgent.mockReturnValue(agent as never);
@@ -406,3 +702,261 @@ describe("useEveAgentRuntime staged messages", () => {
406
702
  expect(agent.send).not.toHaveBeenCalled();
407
703
  });
408
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,7 +23,9 @@ import {
23
23
  useEveAgent,
24
24
  type EveMessageData,
25
25
  type UseEveAgentOptions,
26
+ type UseEveAgentStatus,
26
27
  } from "eve/react";
28
+ import type { SendTurnPayload } from "eve/client";
27
29
  import {
28
30
  convertEveMessages,
29
31
  getEveMessageContent,
@@ -35,6 +37,32 @@ const USER_STAGED_STATUS = {
35
37
  reason: "unknown",
36
38
  } as const;
37
39
 
40
+ const sendCancelledError = new Error(
41
+ "eve send was dropped because the run was cancelled.",
42
+ );
43
+
44
+ const hasRunConfig = (
45
+ runConfig: AppendMessage["runConfig"],
46
+ ): runConfig is NonNullable<AppendMessage["runConfig"]> =>
47
+ runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;
48
+
49
+ /**
50
+ * Only the `custom` bag crosses the wire. Eve reads `clientContext` as its own
51
+ * namespace and serializes it into a model-visible context message, so sending
52
+ * the assistant-ui envelope would surface a literal `"custom"` key in the
53
+ * prompt and to every eve-side handler.
54
+ */
55
+ const toEveClientContext = (
56
+ runConfig: AppendMessage["runConfig"],
57
+ ): Pick<SendTurnPayload, "clientContext"> =>
58
+ hasRunConfig(runConfig)
59
+ ? {
60
+ clientContext: runConfig.custom as NonNullable<
61
+ SendTurnPayload["clientContext"]
62
+ >,
63
+ }
64
+ : {};
65
+
38
66
  export type UseEveAgentRuntimeOptions = Omit<
39
67
  UseEveAgentOptions<EveMessageData>,
40
68
  "reducer"
@@ -72,7 +100,15 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
72
100
  ? true
73
101
  : never;
74
102
 
75
- 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
+ });
76
112
  const runtimeAdapters = useRuntimeAdapters();
77
113
  const [toolStatuses, setToolStatuses] = useState<
78
114
  Record<string, ToolExecutionStatus>
@@ -123,6 +159,31 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
123
159
  const messagesRef = useRef(messages);
124
160
  messagesRef.current = messages;
125
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
+
126
187
  useEffect(() => {
127
188
  if (stagedInputsRef.current.size === 0) return;
128
189
  const baseIds = new Set(convertedMessages.map((message) => message.id));
@@ -182,15 +243,24 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
182
243
  stageUserMessage(message);
183
244
  return;
184
245
  }
185
- await agent.send({ message: getEveMessageContent(message) });
246
+ try {
247
+ await enqueueSend({
248
+ message: getEveMessageContent(message),
249
+ ...toEveClientContext(message.runConfig),
250
+ });
251
+ } catch (error) {
252
+ if (error !== sendCancelledError) throw error;
253
+ }
186
254
  },
187
255
  ...(stagedMessages
188
256
  ? {
189
- onReload: async (parentId: string | null) => {
257
+ onReload: async (parentId: string | null, config) => {
190
258
  const stagedRun = getStagedRun(parentId);
191
259
  if (!stagedRun)
192
260
  throw new Error("Runtime does not support reloading messages.");
261
+ const epoch = sendEpochRef.current;
193
262
  for (const { message: stagedMessage, input } of stagedRun) {
263
+ if (epoch !== sendEpochRef.current) return;
194
264
  const previousMessages = messagesRef.current;
195
265
  stagedInputsRef.current.delete(stagedMessage.id);
196
266
  const nextMessages = previousMessages.filter(
@@ -200,26 +270,41 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
200
270
  setStagedMessages(
201
271
  stagedInputsRef.current.size > 0 ? nextMessages : null,
202
272
  );
273
+ // The reload config belongs to the message being reloaded; the
274
+ // drafts promoted ahead of it keep the config they were staged
275
+ // with, or reloading the tail would rewrite their context too.
276
+ const runConfig =
277
+ stagedMessage.id === parentId && hasRunConfig(config.runConfig)
278
+ ? config.runConfig
279
+ : input.runConfig;
203
280
  try {
204
- await agent.send({
281
+ await enqueueSend({
205
282
  message: getEveMessageContent(input.message),
283
+ ...toEveClientContext(runConfig),
206
284
  });
207
285
  } catch (error) {
208
286
  stagedInputsRef.current.set(stagedMessage.id, input);
209
287
  messagesRef.current = previousMessages;
210
288
  setStagedMessages(previousMessages);
289
+ if (error === sendCancelledError) return;
211
290
  throw error;
212
291
  }
292
+ if (lastFinishStatusRef.current === "error") return;
213
293
  }
214
294
  },
215
295
  }
216
296
  : {}),
217
297
  onCancel: () => {
298
+ sendEpochRef.current += 1;
218
299
  agent.stop();
219
300
  return Promise.resolve();
220
301
  },
221
302
  onRespondToToolApproval: async (response) => {
222
- await agent.send({ inputResponses: [toEveInputResponse(response)] });
303
+ try {
304
+ await enqueueSend({ inputResponses: [toEveInputResponse(response)] });
305
+ } catch (error) {
306
+ if (error !== sendCancelledError) throw error;
307
+ }
223
308
  },
224
309
  });
225
310
  };