@assistant-ui/eve 0.0.16 → 0.0.17
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/dist/deriveCreatedAt.d.ts +23 -0
- package/dist/deriveCreatedAt.d.ts.map +1 -0
- package/dist/deriveCreatedAt.js +79 -0
- package/dist/deriveCreatedAt.js.map +1 -0
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +8 -1
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +5 -5
- package/src/deriveCreatedAt.test.ts +190 -0
- package/src/deriveCreatedAt.ts +96 -0
- package/src/useEveAgentRuntime.eveResume.test.tsx +80 -0
- package/src/useEveAgentRuntime.test.tsx +86 -0
- package/src/useEveAgentRuntime.ts +20 -1
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { MessageStreamEvent } from "eve/client";
|
|
2
|
+
//#region src/deriveCreatedAt.d.ts
|
|
3
|
+
type TurnTimestamps = {
|
|
4
|
+
readonly user?: Date;
|
|
5
|
+
readonly assistant?: Date;
|
|
6
|
+
};
|
|
7
|
+
type TurnTimestampCache = {
|
|
8
|
+
lastEvents: readonly MessageStreamEvent[];
|
|
9
|
+
timestamps: ReadonlyMap<string, TurnTimestamps>;
|
|
10
|
+
};
|
|
11
|
+
declare const createTurnTimestampCache: () => TurnTimestampCache;
|
|
12
|
+
/**
|
|
13
|
+
* Eve appends to its log (`[...events, event]`), so a snapshot that keeps the
|
|
14
|
+
* previous scan's last element by identity shares the whole prefix and the
|
|
15
|
+
* scan resumes there. Any other snapshot is re-derived into a fresh map: turn
|
|
16
|
+
* ids are per-session sequence numbers that recur after `reset()`, so entries
|
|
17
|
+
* must not outlive the log that produced them. Re-derivation is also what
|
|
18
|
+
* makes the render-phase cache writes safe when React discards a render.
|
|
19
|
+
*/
|
|
20
|
+
declare const collectTurnTimestamps: (events: readonly MessageStreamEvent[], cache: TurnTimestampCache) => ReadonlyMap<string, TurnTimestamps>;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { TurnTimestampCache, TurnTimestamps, collectTurnTimestamps, createTurnTimestampCache };
|
|
23
|
+
//# sourceMappingURL=deriveCreatedAt.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deriveCreatedAt.d.ts","names":[],"sources":["../src/deriveCreatedAt.ts"],"mappings":";;KAEY;WACD,OAAO;WACP,YAAY;;KAGX;EACV,qBAAqB;EACrB,YAAY,oBAAoB;;cAiCrB,gCAA+B;;;;;;;;;cAa/B,wBACX,iBAAiB,sBACjB,OAAO,uBACN,oBAAoB"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
//#region src/deriveCreatedAt.ts
|
|
2
|
+
/**
|
|
3
|
+
* The reducer cases eve projects into a message: `message.received` creates
|
|
4
|
+
* `${turnId}:user`, and every assistant entry reaches `updateAssistantMessage`,
|
|
5
|
+
* which creates `${turnId}:assistant` for a turn that has none. It enumerates
|
|
6
|
+
* that reducer across the `>=0.32.0` peer range rather than stating a rule
|
|
7
|
+
* about turn ids, because `turn.started` and the compaction events carry one
|
|
8
|
+
* before the model runs. An event eve adds to the creating set later is absent
|
|
9
|
+
* here and falls back to the wall clock instead of stamping a wrong time.
|
|
10
|
+
*/
|
|
11
|
+
const ROLE_BY_EVENT_TYPE = {
|
|
12
|
+
"message.received": "user",
|
|
13
|
+
"step.started": "assistant",
|
|
14
|
+
"reasoning.appended": "assistant",
|
|
15
|
+
"reasoning.completed": "assistant",
|
|
16
|
+
"action.input.appended": "assistant",
|
|
17
|
+
"actions.requested": "assistant",
|
|
18
|
+
"input.requested": "assistant",
|
|
19
|
+
"action.result": "assistant",
|
|
20
|
+
"action.partial": "assistant",
|
|
21
|
+
"authorization.required": "assistant",
|
|
22
|
+
"authorization.completed": "assistant",
|
|
23
|
+
"message.appended": "assistant",
|
|
24
|
+
"message.completed": "assistant",
|
|
25
|
+
"result.completed": "assistant",
|
|
26
|
+
"turn.completed": "assistant",
|
|
27
|
+
"turn.cancelled": "assistant"
|
|
28
|
+
};
|
|
29
|
+
const createTurnTimestampCache = () => ({
|
|
30
|
+
lastEvents: [],
|
|
31
|
+
timestamps: /* @__PURE__ */ new Map()
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Eve appends to its log (`[...events, event]`), so a snapshot that keeps the
|
|
35
|
+
* previous scan's last element by identity shares the whole prefix and the
|
|
36
|
+
* scan resumes there. Any other snapshot is re-derived into a fresh map: turn
|
|
37
|
+
* ids are per-session sequence numbers that recur after `reset()`, so entries
|
|
38
|
+
* must not outlive the log that produced them. Re-derivation is also what
|
|
39
|
+
* makes the render-phase cache writes safe when React discards a render.
|
|
40
|
+
*/
|
|
41
|
+
const collectTurnTimestamps = (events, cache) => {
|
|
42
|
+
if (events === cache.lastEvents) return cache.timestamps;
|
|
43
|
+
const scanned = cache.lastEvents;
|
|
44
|
+
const resumesScan = scanned.length === 0 || events[scanned.length - 1] === scanned[scanned.length - 1];
|
|
45
|
+
let timestamps = cache.timestamps;
|
|
46
|
+
let draft;
|
|
47
|
+
if (!resumesScan) {
|
|
48
|
+
draft = /* @__PURE__ */ new Map();
|
|
49
|
+
timestamps = draft;
|
|
50
|
+
}
|
|
51
|
+
for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
|
|
52
|
+
const event = events[i];
|
|
53
|
+
const role = ROLE_BY_EVENT_TYPE[event.type];
|
|
54
|
+
if (role === void 0) continue;
|
|
55
|
+
const data = event.data;
|
|
56
|
+
if (typeof data !== "object" || data === null) continue;
|
|
57
|
+
const turnId = data.turnId;
|
|
58
|
+
if (typeof turnId !== "string") continue;
|
|
59
|
+
const known = timestamps.get(turnId);
|
|
60
|
+
if (known?.[role] !== void 0) continue;
|
|
61
|
+
const at = event.meta?.at;
|
|
62
|
+
if (typeof at !== "string") continue;
|
|
63
|
+
const date = new Date(at);
|
|
64
|
+
if (Number.isNaN(date.getTime())) continue;
|
|
65
|
+
draft ??= new Map(timestamps);
|
|
66
|
+
draft.set(turnId, {
|
|
67
|
+
...known,
|
|
68
|
+
[role]: date
|
|
69
|
+
});
|
|
70
|
+
timestamps = draft;
|
|
71
|
+
}
|
|
72
|
+
cache.lastEvents = events;
|
|
73
|
+
cache.timestamps = timestamps;
|
|
74
|
+
return timestamps;
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
export { collectTurnTimestamps, createTurnTimestampCache };
|
|
78
|
+
|
|
79
|
+
//# sourceMappingURL=deriveCreatedAt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deriveCreatedAt.js","names":[],"sources":["../src/deriveCreatedAt.ts"],"sourcesContent":["import type { MessageStreamEvent } from \"eve/client\";\n\nexport type TurnTimestamps = {\n readonly user?: Date;\n readonly assistant?: Date;\n};\n\nexport type TurnTimestampCache = {\n lastEvents: readonly MessageStreamEvent[];\n timestamps: ReadonlyMap<string, TurnTimestamps>;\n};\n\n/**\n * The reducer cases eve projects into a message: `message.received` creates\n * `${turnId}:user`, and every assistant entry reaches `updateAssistantMessage`,\n * which creates `${turnId}:assistant` for a turn that has none. It enumerates\n * that reducer across the `>=0.32.0` peer range rather than stating a rule\n * about turn ids, because `turn.started` and the compaction events carry one\n * before the model runs. An event eve adds to the creating set later is absent\n * here and falls back to the wall clock instead of stamping a wrong time.\n */\nconst ROLE_BY_EVENT_TYPE: Partial<\n Record<MessageStreamEvent[\"type\"], keyof TurnTimestamps>\n> = {\n \"message.received\": \"user\",\n \"step.started\": \"assistant\",\n \"reasoning.appended\": \"assistant\",\n \"reasoning.completed\": \"assistant\",\n \"action.input.appended\": \"assistant\",\n \"actions.requested\": \"assistant\",\n \"input.requested\": \"assistant\",\n \"action.result\": \"assistant\",\n \"action.partial\": \"assistant\",\n \"authorization.required\": \"assistant\",\n \"authorization.completed\": \"assistant\",\n \"message.appended\": \"assistant\",\n \"message.completed\": \"assistant\",\n \"result.completed\": \"assistant\",\n \"turn.completed\": \"assistant\",\n \"turn.cancelled\": \"assistant\",\n};\n\nexport const createTurnTimestampCache = (): TurnTimestampCache => ({\n lastEvents: [],\n timestamps: new Map(),\n});\n\n/**\n * Eve appends to its log (`[...events, event]`), so a snapshot that keeps the\n * previous scan's last element by identity shares the whole prefix and the\n * scan resumes there. Any other snapshot is re-derived into a fresh map: turn\n * ids are per-session sequence numbers that recur after `reset()`, so entries\n * must not outlive the log that produced them. Re-derivation is also what\n * makes the render-phase cache writes safe when React discards a render.\n */\nexport const collectTurnTimestamps = (\n events: readonly MessageStreamEvent[],\n cache: TurnTimestampCache,\n): ReadonlyMap<string, TurnTimestamps> => {\n if (events === cache.lastEvents) return cache.timestamps;\n\n const scanned = cache.lastEvents;\n const resumesScan =\n scanned.length === 0 ||\n events[scanned.length - 1] === scanned[scanned.length - 1];\n\n let timestamps = cache.timestamps;\n let draft: Map<string, TurnTimestamps> | undefined;\n if (!resumesScan) {\n draft = new Map();\n timestamps = draft;\n }\n for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {\n const event = events[i]!;\n const role = ROLE_BY_EVENT_TYPE[event.type];\n if (role === undefined) continue;\n const data: unknown = (event as { readonly data?: unknown }).data;\n if (typeof data !== \"object\" || data === null) continue;\n const turnId = (data as { readonly turnId?: unknown }).turnId;\n if (typeof turnId !== \"string\") continue;\n const known = timestamps.get(turnId);\n if (known?.[role] !== undefined) continue;\n const at: unknown = (event as { readonly meta?: { readonly at?: unknown } })\n .meta?.at;\n if (typeof at !== \"string\") continue;\n const date = new Date(at);\n if (Number.isNaN(date.getTime())) continue;\n draft ??= new Map(timestamps);\n draft.set(turnId, { ...known, [role]: date });\n timestamps = draft;\n }\n\n cache.lastEvents = events;\n cache.timestamps = timestamps;\n return timestamps;\n};\n"],"mappings":";;;;;;;;;;AAqBA,MAAM,qBAEF;CACF,oBAAoB;CACpB,gBAAgB;CAChB,sBAAsB;CACtB,uBAAuB;CACvB,yBAAyB;CACzB,qBAAqB;CACrB,mBAAmB;CACnB,iBAAiB;CACjB,kBAAkB;CAClB,0BAA0B;CAC1B,2BAA2B;CAC3B,oBAAoB;CACpB,qBAAqB;CACrB,oBAAoB;CACpB,kBAAkB;CAClB,kBAAkB;AACpB;AAEA,MAAa,kCAAsD;CACjE,YAAY,CAAC;CACb,4BAAY,IAAI,IAAI;AACtB;;;;;;;;;AAUA,MAAa,yBACX,QACA,UACwC;CACxC,IAAI,WAAW,MAAM,YAAY,OAAO,MAAM;CAE9C,MAAM,UAAU,MAAM;CACtB,MAAM,cACJ,QAAQ,WAAW,KACnB,OAAO,QAAQ,SAAS,OAAO,QAAQ,QAAQ,SAAS;CAE1D,IAAI,aAAa,MAAM;CACvB,IAAI;CACJ,IAAI,CAAC,aAAa;EAChB,wBAAQ,IAAI,IAAI;EAChB,aAAa;CACf;CACA,KAAK,IAAI,IAAI,cAAc,QAAQ,SAAS,GAAG,IAAI,OAAO,QAAQ,KAAK;EACrE,MAAM,QAAQ,OAAO;EACrB,MAAM,OAAO,mBAAmB,MAAM;EACtC,IAAI,SAAS,KAAA,GAAW;EACxB,MAAM,OAAiB,MAAsC;EAC7D,IAAI,OAAO,SAAS,YAAY,SAAS,MAAM;EAC/C,MAAM,SAAU,KAAuC;EACvD,IAAI,OAAO,WAAW,UAAU;EAChC,MAAM,QAAQ,WAAW,IAAI,MAAM;EACnC,IAAI,QAAQ,UAAU,KAAA,GAAW;EACjC,MAAM,KAAe,MAClB,MAAM;EACT,IAAI,OAAO,OAAO,UAAU;EAC5B,MAAM,OAAO,IAAI,KAAK,EAAE;EACxB,IAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,GAAG;EAClC,UAAU,IAAI,IAAI,UAAU;EAC5B,MAAM,IAAI,QAAQ;GAAE,GAAG;IAAQ,OAAO;EAAK,CAAC;EAC5C,aAAa;CACf;CAEA,MAAM,aAAa;CACnB,MAAM,aAAa;CACnB,OAAO;AACT"}
|
|
@@ -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":";;;KA+HY,4BAA4B,KACtC,mBAAmB,8BAGnB;WACW;aAEM,cAAc;aACd,SAAS;aACT,YAAY;aACZ,QAAQ;aACR,WAAW;;;;;;;;;;cAYjB,qBAAsB,UAAS,2DAA8B"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { convertEveMessages, findEveInputRequest, getEveMessageContent, toEveInputResponse } from "./convertEveMessages.js";
|
|
3
|
+
import { collectTurnTimestamps, createTurnTimestampCache } from "./deriveCreatedAt.js";
|
|
3
4
|
import { eveExtras } from "./eveExtras.js";
|
|
4
5
|
import { MessageNotSentError, fromThreadMessageLike, generateId, pickExternalStoreSharedOptions } from "@assistant-ui/core";
|
|
5
6
|
import { useExternalStoreRuntime, useRuntimeAdapters } from "@assistant-ui/core/react";
|
|
@@ -63,6 +64,8 @@ const useEveAgentRuntime = (options = {}) => {
|
|
|
63
64
|
const hasExecutingTools = Object.values(toolStatuses).some((status) => status?.type === "executing");
|
|
64
65
|
const providerIsRunning = agent.status === "submitted" || agent.status === "streaming";
|
|
65
66
|
const isRunning = providerIsRunning || hasExecutingTools;
|
|
67
|
+
const turnTimestampCacheRef = useRef(createTurnTimestampCache());
|
|
68
|
+
const turnTimestamps = useMemo(() => collectTurnTimestamps(agent.events, turnTimestampCacheRef.current), [agent.events]);
|
|
66
69
|
const convertedMessages = useMemo(() => {
|
|
67
70
|
const createdAtByMessageId = createdAtByMessageIdRef.current;
|
|
68
71
|
const messageIds = new Set(agent.data.messages.map((message) => message.id));
|
|
@@ -71,6 +74,9 @@ const useEveAgentRuntime = (options = {}) => {
|
|
|
71
74
|
isRunning,
|
|
72
75
|
error: agent.error,
|
|
73
76
|
getCreatedAt: (message) => {
|
|
77
|
+
const turnId = message.metadata?.turnId;
|
|
78
|
+
const durable = turnId === void 0 ? void 0 : turnTimestamps.get(turnId)?.[message.role];
|
|
79
|
+
if (durable !== void 0) return durable;
|
|
74
80
|
const existing = createdAtByMessageId.get(message.id);
|
|
75
81
|
if (existing) return existing;
|
|
76
82
|
const createdAt = /* @__PURE__ */ new Date();
|
|
@@ -81,7 +87,8 @@ const useEveAgentRuntime = (options = {}) => {
|
|
|
81
87
|
}, [
|
|
82
88
|
agent.data,
|
|
83
89
|
agent.error,
|
|
84
|
-
isRunning
|
|
90
|
+
isRunning,
|
|
91
|
+
turnTimestamps
|
|
85
92
|
]);
|
|
86
93
|
const messages = stagedMessages ?? convertedMessages;
|
|
87
94
|
const messagesRef = useRef(messages);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useEveAgentRuntime.js","names":[],"sources":["../src/useEveAgentRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useInsertionEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n fromThreadMessageLike,\n generateId,\n MessageNotSentError,\n pickExternalStoreSharedOptions,\n type AppendMessage,\n type AttachmentAdapter,\n type DictationAdapter,\n type ExternalStoreSharedOptions,\n type FeedbackAdapter,\n type RealtimeVoiceAdapter,\n type SpeechSynthesisAdapter,\n type ThreadMessage,\n type ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport {\n useExternalStoreRuntime,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport {\n useEveAgent,\n type EveMessageData,\n type UseEveAgentOptions,\n type UseEveAgentStatus,\n} from \"eve/react\";\nimport {\n convertEveMessages,\n findEveInputRequest,\n getEveMessageContent,\n toEveInputResponse,\n} from \"./convertEveMessages\";\nimport { eveExtras } from \"./eveExtras\";\n\nconst USER_STAGED_STATUS = {\n type: \"complete\",\n reason: \"unknown\",\n} as const;\n\nconst sendCancelledError = new MessageNotSentError(\n \"eve send was dropped because the run was cancelled.\",\n);\n\nconst sendAbandonedError = new Error(\n \"eve send was dropped because the runtime unmounted.\",\n);\n\nconst isDroppedSend = (error: unknown) =>\n error === sendCancelledError || error === sendAbandonedError;\n\ntype EveLifecycleCallbackName =\n | \"onError\"\n | \"onEvent\"\n | \"onFinish\"\n | \"onSessionChange\";\n\nconst reportEveLifecycleCallbackError = (\n name: EveLifecycleCallbackName,\n error: unknown,\n) => {\n console.error(`[assistant-ui/eve] ${name} callback threw an error`, error);\n};\n\nconst invokeEveLifecycleCallback = <T>(\n name: EveLifecycleCallbackName,\n callback: ((value: T) => unknown) | undefined,\n value: T,\n) => {\n if (!callback) return;\n\n try {\n void Promise.resolve(callback(value)).catch((error) => {\n reportEveLifecycleCallbackError(name, error);\n });\n } catch (error) {\n reportEveLifecycleCallbackError(name, error);\n }\n};\n\nconst hasRunConfig = (\n runConfig: AppendMessage[\"runConfig\"],\n): runConfig is NonNullable<AppendMessage[\"runConfig\"]> =>\n runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;\n\ntype EveJsonValue =\n | string\n | number\n | boolean\n | null\n | readonly EveJsonValue[]\n | { readonly [key: string]: EveJsonValue };\n\n/**\n * Structural equivalent of eve's `JsonObject`. Eve demoted its public send\n * payload type once already (`SendTurnPayload`, public through 0.30, internal\n * from 0.31), so the helpers declare the shapes they forward instead of\n * re-exporting eve's option types, and assignability is checked at the send\n * call sites against the installed version.\n */\ntype EveClientContext = { readonly [key: string]: EveJsonValue };\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 toEveSendOptions = (\n runConfig: AppendMessage[\"runConfig\"],\n): { readonly clientContext: EveClientContext } | undefined =>\n hasRunConfig(runConfig)\n ? { clientContext: runConfig.custom as EveClientContext }\n : undefined;\n\nexport type UseEveAgentRuntimeOptions = Omit<\n UseEveAgentOptions<EveMessageData>,\n \"reducer\"\n> &\n ExternalStoreSharedOptions & {\n readonly adapters?:\n | {\n readonly attachments?: AttachmentAdapter | undefined;\n readonly speech?: SpeechSynthesisAdapter | undefined;\n readonly dictation?: DictationAdapter | undefined;\n readonly voice?: RealtimeVoiceAdapter | undefined;\n readonly feedback?: FeedbackAdapter | undefined;\n }\n | undefined;\n };\n\n/**\n * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.\n *\n * The runtime renders Eve messages, forwards new user messages to the Eve\n * session, supports cancellation, and maps Eve input requests to assistant-ui\n * tool approval UI.\n */\nexport const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {\n const {\n adapters,\n isDisabled: _isDisabled,\n isSendDisabled: _isSendDisabled,\n suggestions: _suggestions,\n unstable_capabilities: _unstable_capabilities,\n ...agentOptions\n } = options;\n true satisfies keyof typeof agentOptions &\n keyof ExternalStoreSharedOptions extends never\n ? true\n : never;\n\n const { onError, onEvent, onFinish, onSessionChange } = agentOptions;\n const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);\n const agent = useEveAgent({\n ...agentOptions,\n ...(onError\n ? {\n onError: (error) =>\n invokeEveLifecycleCallback(\"onError\", onError, error),\n }\n : {}),\n ...(onEvent\n ? {\n onEvent: (event) =>\n invokeEveLifecycleCallback(\"onEvent\", onEvent, event),\n }\n : {}),\n onFinish: (snapshot) => {\n lastFinishStatusRef.current = snapshot.status;\n invokeEveLifecycleCallback(\"onFinish\", onFinish, snapshot);\n },\n ...(onSessionChange\n ? {\n onSessionChange: (session) =>\n invokeEveLifecycleCallback(\n \"onSessionChange\",\n onSessionChange,\n session,\n ),\n }\n : {}),\n });\n const runtimeAdapters = useRuntimeAdapters();\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n const [stagedMessages, setStagedMessages] = useState<ThreadMessage[] | null>(\n null,\n );\n const createdAtByMessageIdRef = useRef(new Map<string, Date>());\n const stagedInputsRef = useRef(\n new Map<\n string,\n { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] }\n >(),\n );\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (status) => status?.type === \"executing\",\n );\n const providerIsRunning =\n agent.status === \"submitted\" || agent.status === \"streaming\";\n const isRunning = providerIsRunning || 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 const agentRef = useRef(agent);\n // Descendant layout effects can dispatch against these refs synchronously;\n // this matches useA2ARuntime's commit-scoped ref publication.\n useInsertionEffect(() => {\n messagesRef.current = messages;\n agentRef.current = agent;\n }, [agent, messages]);\n\n // Upstream `EveAgentStore` `send` and `respond` reject while a turn is in\n // flight and only resolve once the turn's stream parks, so a pending chain\n // link is exactly an active turn; chaining every dispatch serializes them\n // without watching status.\n const sendChainRef = useRef<Promise<void>>(Promise.resolve());\n const sendEpochRef = useRef(0);\n // A cancel drops the queued send but keeps its draft for a later promotion;\n // only a reset discards the draft with the session, so the two need separate\n // counters. A queued read has no draft and outlives a cancel, so it waits on\n // the reset counter instead.\n const resetEpochRef = useRef(0);\n const isMountedRef = useRef(true);\n const runtimeRef = useRef<ReturnType<typeof useExternalStoreRuntime> | null>(\n null,\n );\n\n const enqueueSend = (\n dispatch: () => Promise<void>,\n epochRef: { current: number } = sendEpochRef,\n ) => {\n const epoch = epochRef.current;\n const next = sendChainRef.current.then(() => {\n if (!isMountedRef.current) throw sendAbandonedError;\n if (epoch !== epochRef.current) throw sendCancelledError;\n return dispatch();\n });\n sendChainRef.current = next.catch(() => {});\n return next;\n };\n\n // The store outlives the component (useEveAgent holds it in a ref with no\n // cleanup), so queued sends must not fire server turns after unmount. The\n // flag separates that teardown from a user cancel, and is re-armed in setup\n // for a remounted tree.\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n sendEpochRef.current += 1;\n };\n }, []);\n\n useEffect(() => {\n if (stagedInputsRef.current.size === 0) return;\n const baseIds = new Set(convertedMessages.map((message) => message.id));\n const remaining = messagesRef.current.filter(\n (message) =>\n stagedInputsRef.current.has(message.id) && !baseIds.has(message.id),\n );\n setStagedMessages(\n remaining.length > 0 ? [...convertedMessages, ...remaining] : null,\n );\n }, [convertedMessages]);\n\n const getStagedRun = (parentId: string | null) => {\n if (!parentId || !stagedInputsRef.current.has(parentId)) return null;\n const staged: {\n message: ThreadMessage;\n input: { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] };\n }[] = [];\n for (const message of messagesRef.current) {\n const input = stagedInputsRef.current.get(message.id);\n if (input) staged.push({ message, input });\n if (message.id === parentId) break;\n }\n return staged;\n };\n\n const stageUserMessage = (message: AppendMessage) => {\n const threadMessage = fromThreadMessageLike(\n message,\n generateId(),\n USER_STAGED_STATUS,\n );\n stagedInputsRef.current.set(threadMessage.id, {\n message,\n runConfig: message.runConfig,\n });\n const nextMessages = [...messagesRef.current, threadMessage];\n messagesRef.current = nextMessages;\n setStagedMessages(nextMessages);\n };\n\n const reset = useCallback(() => {\n runtimeRef.current?.thread.unstable_notifySessionReset();\n // Sends parked behind an active turn captured the pre-reset epoch, so the\n // epoch has to advance before the session is torn down or they dispatch\n // into the new one; `lastFinishStatusRef` is run-scoped for the same\n // reason.\n sendEpochRef.current += 1;\n resetEpochRef.current += 1;\n lastFinishStatusRef.current = null;\n setStagedMessages(null);\n stagedInputsRef.current.clear();\n setToolStatuses({});\n agent.reset();\n }, [agent]);\n\n const extras = useMemo(\n () =>\n eveExtras.provide({\n error: agent.error,\n events: agent.events,\n session: agent.session,\n reset,\n }),\n [agent.error, agent.events, agent.session, reset],\n );\n\n const runtime = useExternalStoreRuntime({\n ...pickExternalStoreSharedOptions(options),\n messages,\n isRunning: providerIsRunning,\n extras,\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 agent.send(\n getEveMessageContent(message),\n toEveSendOptions(message.runConfig),\n ),\n );\n } catch (error) {\n // A cancelled send never reached the session, so it rethrows for the\n // composer to take the draft back; an unmounted one has no composer\n // left to restore.\n if (error === sendAbandonedError) return;\n throw error;\n }\n },\n ...(stagedMessages\n ? {\n onReload: async (parentId: string | null, config) => {\n const stagedRun = getStagedRun(parentId);\n if (!stagedRun)\n throw new Error(\"Runtime does not support reloading messages.\");\n const epoch = sendEpochRef.current;\n const resetEpoch = resetEpochRef.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 agent.send(\n getEveMessageContent(input.message),\n toEveSendOptions(runConfig),\n ),\n );\n } catch (error) {\n // A reset discarded the draft along with the session, so\n // restoring it here would refloat a message the thread no\n // longer has. A cancel still restores.\n if (resetEpoch !== resetEpochRef.current) return;\n stagedInputsRef.current.set(stagedMessage.id, input);\n messagesRef.current = previousMessages;\n setStagedMessages(previousMessages);\n if (isDroppedSend(error)) return;\n throw error;\n }\n if (lastFinishStatusRef.current === \"error\") return;\n }\n },\n }\n : {}),\n onCancel: async () => {\n sendEpochRef.current += 1;\n // Eve 0.38 replaced the binding's local-abort `stop()` with the durable\n // `cancel()`, so the adapter detects which side of that break the host's\n // eve provides instead of pinning the peer range to one of them.\n const controls = agent as\n | { readonly cancel: () => Promise<unknown> }\n | { readonly stop: () => void };\n if (\"cancel\" in controls) {\n await controls.cancel();\n } else {\n controls.stop();\n }\n },\n // Hosts below eve 0.44.1 expose no `resume`; leaving the capability absent\n // keeps `threads.reloadMainThread()` on core's no-capability no-op.\n ...(\"resume\" in agent\n ? {\n onRefetchThread: () =>\n // `resume()` rejects during a turn, so the replay rides the send\n // chain like every other dispatch and runs once the turn parks; a\n // cancel of that turn does not drop it, or the caller would be told\n // the thread was refetched when it was not. Upstream shares one\n // replay across concurrent `resume()` calls, so a refetch\n // dispatched while `resume: true` is replaying on mount joins that\n // read instead of taking a fresh one. The session is read at\n // dispatch time because nothing durable exists before the first\n // send lands.\n enqueueSend(async () => {\n const live = agentRef.current;\n if (live.session === undefined) return;\n await live.resume();\n }, resetEpochRef).catch((error) => {\n if (isDroppedSend(error)) return;\n throw error;\n }),\n }\n : {}),\n onRespondToToolApproval: (response) => {\n // Eve resolves a request the moment any response for it arrives, and an\n // empty one is recorded as an answer with no content. Mapping before the\n // send is enqueued keeps an unmappable response unsent, so the request\n // stays answerable; the mapper's error reaches the caller as the seam's\n // rejection.\n const inputResponse = toEveInputResponse(\n response,\n findEveInputRequest(agent.data, response.approvalId),\n );\n return enqueueSend(() => agent.respond([inputResponse])).catch(\n (error) => {\n if (!isDroppedSend(error)) throw error;\n },\n );\n },\n });\n runtimeRef.current = runtime;\n\n return runtime;\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useEveAgentRuntime.js","names":[],"sources":["../src/useEveAgentRuntime.ts"],"sourcesContent":["\"use client\";\n\nimport {\n useCallback,\n useEffect,\n useInsertionEffect,\n useMemo,\n useRef,\n useState,\n} from \"react\";\nimport {\n fromThreadMessageLike,\n generateId,\n MessageNotSentError,\n pickExternalStoreSharedOptions,\n type AppendMessage,\n type AttachmentAdapter,\n type DictationAdapter,\n type ExternalStoreSharedOptions,\n type FeedbackAdapter,\n type RealtimeVoiceAdapter,\n type SpeechSynthesisAdapter,\n type ThreadMessage,\n type ToolExecutionStatus,\n} from \"@assistant-ui/core\";\nimport {\n useExternalStoreRuntime,\n useRuntimeAdapters,\n} from \"@assistant-ui/core/react\";\nimport {\n useEveAgent,\n type EveMessageData,\n type UseEveAgentOptions,\n type UseEveAgentStatus,\n} from \"eve/react\";\nimport {\n convertEveMessages,\n findEveInputRequest,\n getEveMessageContent,\n toEveInputResponse,\n} from \"./convertEveMessages\";\nimport {\n collectTurnTimestamps,\n createTurnTimestampCache,\n} from \"./deriveCreatedAt\";\nimport { eveExtras } from \"./eveExtras\";\n\nconst USER_STAGED_STATUS = {\n type: \"complete\",\n reason: \"unknown\",\n} as const;\n\nconst sendCancelledError = new MessageNotSentError(\n \"eve send was dropped because the run was cancelled.\",\n);\n\nconst sendAbandonedError = new Error(\n \"eve send was dropped because the runtime unmounted.\",\n);\n\nconst isDroppedSend = (error: unknown) =>\n error === sendCancelledError || error === sendAbandonedError;\n\ntype EveLifecycleCallbackName =\n | \"onError\"\n | \"onEvent\"\n | \"onFinish\"\n | \"onSessionChange\";\n\nconst reportEveLifecycleCallbackError = (\n name: EveLifecycleCallbackName,\n error: unknown,\n) => {\n console.error(`[assistant-ui/eve] ${name} callback threw an error`, error);\n};\n\nconst invokeEveLifecycleCallback = <T>(\n name: EveLifecycleCallbackName,\n callback: ((value: T) => unknown) | undefined,\n value: T,\n) => {\n if (!callback) return;\n\n try {\n void Promise.resolve(callback(value)).catch((error) => {\n reportEveLifecycleCallbackError(name, error);\n });\n } catch (error) {\n reportEveLifecycleCallbackError(name, error);\n }\n};\n\nconst hasRunConfig = (\n runConfig: AppendMessage[\"runConfig\"],\n): runConfig is NonNullable<AppendMessage[\"runConfig\"]> =>\n runConfig?.custom !== undefined && Object.keys(runConfig.custom).length > 0;\n\ntype EveJsonValue =\n | string\n | number\n | boolean\n | null\n | readonly EveJsonValue[]\n | { readonly [key: string]: EveJsonValue };\n\n/**\n * Structural equivalent of eve's `JsonObject`. Eve demoted its public send\n * payload type once already (`SendTurnPayload`, public through 0.30, internal\n * from 0.31), so the helpers declare the shapes they forward instead of\n * re-exporting eve's option types, and assignability is checked at the send\n * call sites against the installed version.\n */\ntype EveClientContext = { readonly [key: string]: EveJsonValue };\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 toEveSendOptions = (\n runConfig: AppendMessage[\"runConfig\"],\n): { readonly clientContext: EveClientContext } | undefined =>\n hasRunConfig(runConfig)\n ? { clientContext: runConfig.custom as EveClientContext }\n : undefined;\n\nexport type UseEveAgentRuntimeOptions = Omit<\n UseEveAgentOptions<EveMessageData>,\n \"reducer\"\n> &\n ExternalStoreSharedOptions & {\n readonly adapters?:\n | {\n readonly attachments?: AttachmentAdapter | undefined;\n readonly speech?: SpeechSynthesisAdapter | undefined;\n readonly dictation?: DictationAdapter | undefined;\n readonly voice?: RealtimeVoiceAdapter | undefined;\n readonly feedback?: FeedbackAdapter | undefined;\n }\n | undefined;\n };\n\n/**\n * Connects Eve's `useEveAgent` hook to assistant-ui's runtime contract.\n *\n * The runtime renders Eve messages, forwards new user messages to the Eve\n * session, supports cancellation, and maps Eve input requests to assistant-ui\n * tool approval UI.\n */\nexport const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {\n const {\n adapters,\n isDisabled: _isDisabled,\n isSendDisabled: _isSendDisabled,\n suggestions: _suggestions,\n unstable_capabilities: _unstable_capabilities,\n ...agentOptions\n } = options;\n true satisfies keyof typeof agentOptions &\n keyof ExternalStoreSharedOptions extends never\n ? true\n : never;\n\n const { onError, onEvent, onFinish, onSessionChange } = agentOptions;\n const lastFinishStatusRef = useRef<UseEveAgentStatus | null>(null);\n const agent = useEveAgent({\n ...agentOptions,\n ...(onError\n ? {\n onError: (error) =>\n invokeEveLifecycleCallback(\"onError\", onError, error),\n }\n : {}),\n ...(onEvent\n ? {\n onEvent: (event) =>\n invokeEveLifecycleCallback(\"onEvent\", onEvent, event),\n }\n : {}),\n onFinish: (snapshot) => {\n lastFinishStatusRef.current = snapshot.status;\n invokeEveLifecycleCallback(\"onFinish\", onFinish, snapshot);\n },\n ...(onSessionChange\n ? {\n onSessionChange: (session) =>\n invokeEveLifecycleCallback(\n \"onSessionChange\",\n onSessionChange,\n session,\n ),\n }\n : {}),\n });\n const runtimeAdapters = useRuntimeAdapters();\n const [toolStatuses, setToolStatuses] = useState<\n Record<string, ToolExecutionStatus>\n >({});\n const [stagedMessages, setStagedMessages] = useState<ThreadMessage[] | null>(\n null,\n );\n const createdAtByMessageIdRef = useRef(new Map<string, Date>());\n const stagedInputsRef = useRef(\n new Map<\n string,\n { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] }\n >(),\n );\n\n const hasExecutingTools = Object.values(toolStatuses).some(\n (status) => status?.type === \"executing\",\n );\n const providerIsRunning =\n agent.status === \"submitted\" || agent.status === \"streaming\";\n const isRunning = providerIsRunning || hasExecutingTools;\n\n // Kept apart from the message memo so events that teach no timestamp keep\n // the map identity and skip rebuilding every ThreadMessage.\n const turnTimestampCacheRef = useRef(createTurnTimestampCache());\n const turnTimestamps = useMemo(\n () => collectTurnTimestamps(agent.events, turnTimestampCacheRef.current),\n [agent.events],\n );\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 turnId = message.metadata?.turnId;\n const durable =\n turnId === undefined\n ? undefined\n : turnTimestamps.get(turnId)?.[message.role];\n if (durable !== undefined) return durable;\n\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, turnTimestamps]);\n\n const messages = stagedMessages ?? convertedMessages;\n const messagesRef = useRef(messages);\n const agentRef = useRef(agent);\n // Descendant layout effects can dispatch against these refs synchronously;\n // this matches useA2ARuntime's commit-scoped ref publication.\n useInsertionEffect(() => {\n messagesRef.current = messages;\n agentRef.current = agent;\n }, [agent, messages]);\n\n // Upstream `EveAgentStore` `send` and `respond` reject while a turn is in\n // flight and only resolve once the turn's stream parks, so a pending chain\n // link is exactly an active turn; chaining every dispatch serializes them\n // without watching status.\n const sendChainRef = useRef<Promise<void>>(Promise.resolve());\n const sendEpochRef = useRef(0);\n // A cancel drops the queued send but keeps its draft for a later promotion;\n // only a reset discards the draft with the session, so the two need separate\n // counters. A queued read has no draft and outlives a cancel, so it waits on\n // the reset counter instead.\n const resetEpochRef = useRef(0);\n const isMountedRef = useRef(true);\n const runtimeRef = useRef<ReturnType<typeof useExternalStoreRuntime> | null>(\n null,\n );\n\n const enqueueSend = (\n dispatch: () => Promise<void>,\n epochRef: { current: number } = sendEpochRef,\n ) => {\n const epoch = epochRef.current;\n const next = sendChainRef.current.then(() => {\n if (!isMountedRef.current) throw sendAbandonedError;\n if (epoch !== epochRef.current) throw sendCancelledError;\n return dispatch();\n });\n sendChainRef.current = next.catch(() => {});\n return next;\n };\n\n // The store outlives the component (useEveAgent holds it in a ref with no\n // cleanup), so queued sends must not fire server turns after unmount. The\n // flag separates that teardown from a user cancel, and is re-armed in setup\n // for a remounted tree.\n useEffect(() => {\n isMountedRef.current = true;\n return () => {\n isMountedRef.current = false;\n sendEpochRef.current += 1;\n };\n }, []);\n\n useEffect(() => {\n if (stagedInputsRef.current.size === 0) return;\n const baseIds = new Set(convertedMessages.map((message) => message.id));\n const remaining = messagesRef.current.filter(\n (message) =>\n stagedInputsRef.current.has(message.id) && !baseIds.has(message.id),\n );\n setStagedMessages(\n remaining.length > 0 ? [...convertedMessages, ...remaining] : null,\n );\n }, [convertedMessages]);\n\n const getStagedRun = (parentId: string | null) => {\n if (!parentId || !stagedInputsRef.current.has(parentId)) return null;\n const staged: {\n message: ThreadMessage;\n input: { message: AppendMessage; runConfig: AppendMessage[\"runConfig\"] };\n }[] = [];\n for (const message of messagesRef.current) {\n const input = stagedInputsRef.current.get(message.id);\n if (input) staged.push({ message, input });\n if (message.id === parentId) break;\n }\n return staged;\n };\n\n const stageUserMessage = (message: AppendMessage) => {\n const threadMessage = fromThreadMessageLike(\n message,\n generateId(),\n USER_STAGED_STATUS,\n );\n stagedInputsRef.current.set(threadMessage.id, {\n message,\n runConfig: message.runConfig,\n });\n const nextMessages = [...messagesRef.current, threadMessage];\n messagesRef.current = nextMessages;\n setStagedMessages(nextMessages);\n };\n\n const reset = useCallback(() => {\n runtimeRef.current?.thread.unstable_notifySessionReset();\n // Sends parked behind an active turn captured the pre-reset epoch, so the\n // epoch has to advance before the session is torn down or they dispatch\n // into the new one; `lastFinishStatusRef` is run-scoped for the same\n // reason.\n sendEpochRef.current += 1;\n resetEpochRef.current += 1;\n lastFinishStatusRef.current = null;\n setStagedMessages(null);\n stagedInputsRef.current.clear();\n setToolStatuses({});\n agent.reset();\n }, [agent]);\n\n const extras = useMemo(\n () =>\n eveExtras.provide({\n error: agent.error,\n events: agent.events,\n session: agent.session,\n reset,\n }),\n [agent.error, agent.events, agent.session, reset],\n );\n\n const runtime = useExternalStoreRuntime({\n ...pickExternalStoreSharedOptions(options),\n messages,\n isRunning: providerIsRunning,\n extras,\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 agent.send(\n getEveMessageContent(message),\n toEveSendOptions(message.runConfig),\n ),\n );\n } catch (error) {\n // A cancelled send never reached the session, so it rethrows for the\n // composer to take the draft back; an unmounted one has no composer\n // left to restore.\n if (error === sendAbandonedError) return;\n throw error;\n }\n },\n ...(stagedMessages\n ? {\n onReload: async (parentId: string | null, config) => {\n const stagedRun = getStagedRun(parentId);\n if (!stagedRun)\n throw new Error(\"Runtime does not support reloading messages.\");\n const epoch = sendEpochRef.current;\n const resetEpoch = resetEpochRef.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 agent.send(\n getEveMessageContent(input.message),\n toEveSendOptions(runConfig),\n ),\n );\n } catch (error) {\n // A reset discarded the draft along with the session, so\n // restoring it here would refloat a message the thread no\n // longer has. A cancel still restores.\n if (resetEpoch !== resetEpochRef.current) return;\n stagedInputsRef.current.set(stagedMessage.id, input);\n messagesRef.current = previousMessages;\n setStagedMessages(previousMessages);\n if (isDroppedSend(error)) return;\n throw error;\n }\n if (lastFinishStatusRef.current === \"error\") return;\n }\n },\n }\n : {}),\n onCancel: async () => {\n sendEpochRef.current += 1;\n // Eve 0.38 replaced the binding's local-abort `stop()` with the durable\n // `cancel()`, so the adapter detects which side of that break the host's\n // eve provides instead of pinning the peer range to one of them.\n const controls = agent as\n | { readonly cancel: () => Promise<unknown> }\n | { readonly stop: () => void };\n if (\"cancel\" in controls) {\n await controls.cancel();\n } else {\n controls.stop();\n }\n },\n // Hosts below eve 0.44.1 expose no `resume`; leaving the capability absent\n // keeps `threads.reloadMainThread()` on core's no-capability no-op.\n ...(\"resume\" in agent\n ? {\n onRefetchThread: () =>\n // `resume()` rejects during a turn, so the replay rides the send\n // chain like every other dispatch and runs once the turn parks; a\n // cancel of that turn does not drop it, or the caller would be told\n // the thread was refetched when it was not. Upstream shares one\n // replay across concurrent `resume()` calls, so a refetch\n // dispatched while `resume: true` is replaying on mount joins that\n // read instead of taking a fresh one. The session is read at\n // dispatch time because nothing durable exists before the first\n // send lands.\n enqueueSend(async () => {\n const live = agentRef.current;\n if (live.session === undefined) return;\n await live.resume();\n }, resetEpochRef).catch((error) => {\n if (isDroppedSend(error)) return;\n throw error;\n }),\n }\n : {}),\n onRespondToToolApproval: (response) => {\n // Eve resolves a request the moment any response for it arrives, and an\n // empty one is recorded as an answer with no content. Mapping before the\n // send is enqueued keeps an unmappable response unsent, so the request\n // stays answerable; the mapper's error reaches the caller as the seam's\n // rejection.\n const inputResponse = toEveInputResponse(\n response,\n findEveInputRequest(agent.data, response.approvalId),\n );\n return enqueueSend(() => agent.respond([inputResponse])).catch(\n (error) => {\n if (!isDroppedSend(error)) throw error;\n },\n );\n },\n });\n runtimeRef.current = runtime;\n\n return runtime;\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.17",
|
|
4
4
|
"description": "Eve runtime adapter for assistant-ui",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eve",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
],
|
|
29
29
|
"sideEffects": false,
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@assistant-ui/core": "^0.3.
|
|
32
|
-
"@assistant-ui/store": "^0.3.
|
|
31
|
+
"@assistant-ui/core": "^0.3.18",
|
|
32
|
+
"@assistant-ui/store": "^0.3.13"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
35
|
"@types/react": "*",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@assistant-ui/x-buildutils": "0.0.
|
|
45
|
+
"@assistant-ui/x-buildutils": "0.0.26",
|
|
46
46
|
"@testing-library/dom": "^10.4.1",
|
|
47
47
|
"@testing-library/react": "^16.3.3",
|
|
48
48
|
"@types/react": "^19.2.18",
|
|
49
|
-
"eve": "^0.
|
|
49
|
+
"eve": "^0.52.2",
|
|
50
50
|
"jsdom": "^30.0.1",
|
|
51
51
|
"react": "^19.2.8",
|
|
52
52
|
"react-dom": "^19.2.8",
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
collectTurnTimestamps,
|
|
6
|
+
createTurnTimestampCache,
|
|
7
|
+
} from "./deriveCreatedAt";
|
|
8
|
+
|
|
9
|
+
const TURN = "turn_1";
|
|
10
|
+
|
|
11
|
+
const turnStarted = (at: string, turnId = TURN) =>
|
|
12
|
+
({
|
|
13
|
+
type: "turn.started",
|
|
14
|
+
data: { sequence: 1, turnId },
|
|
15
|
+
meta: { at, id: `evt_${at}` },
|
|
16
|
+
}) as const satisfies MessageStreamEvent;
|
|
17
|
+
|
|
18
|
+
const messageReceived = (at: string, turnId = TURN) =>
|
|
19
|
+
({
|
|
20
|
+
type: "message.received",
|
|
21
|
+
data: { message: "hi", sequence: 2, turnId },
|
|
22
|
+
meta: { at, id: `evt_${at}` },
|
|
23
|
+
}) as const satisfies MessageStreamEvent;
|
|
24
|
+
|
|
25
|
+
const compactionRequested = (at: string, turnId = TURN) =>
|
|
26
|
+
({
|
|
27
|
+
type: "compaction.requested",
|
|
28
|
+
data: {
|
|
29
|
+
modelId: "m",
|
|
30
|
+
sequence: 3,
|
|
31
|
+
sessionId: "s",
|
|
32
|
+
turnId,
|
|
33
|
+
usageInputTokens: null,
|
|
34
|
+
},
|
|
35
|
+
meta: { at, id: `evt_${at}` },
|
|
36
|
+
}) as const satisfies MessageStreamEvent;
|
|
37
|
+
|
|
38
|
+
const messageAppended = (at: string, turnId = TURN) =>
|
|
39
|
+
({
|
|
40
|
+
type: "message.appended",
|
|
41
|
+
data: {
|
|
42
|
+
messageDelta: "he",
|
|
43
|
+
messageSoFar: "he",
|
|
44
|
+
sequence: 4,
|
|
45
|
+
stepIndex: 0,
|
|
46
|
+
turnId,
|
|
47
|
+
},
|
|
48
|
+
meta: { at, id: `evt_${at}` },
|
|
49
|
+
}) as const satisfies MessageStreamEvent;
|
|
50
|
+
|
|
51
|
+
const stepStarted = (at: string, turnId = TURN) =>
|
|
52
|
+
({
|
|
53
|
+
type: "step.started",
|
|
54
|
+
data: { modelId: "m", sequence: 4, stepIndex: 0, turnId },
|
|
55
|
+
meta: { at, id: `evt_${at}` },
|
|
56
|
+
}) as const satisfies MessageStreamEvent;
|
|
57
|
+
|
|
58
|
+
describe("collectTurnTimestamps", () => {
|
|
59
|
+
it("stamps the user at message.received and the assistant at step.started, skipping the turn's other events", () => {
|
|
60
|
+
const timestamps = collectTurnTimestamps(
|
|
61
|
+
[
|
|
62
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
63
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
64
|
+
compactionRequested("2026-01-02T10:00:02.000Z"),
|
|
65
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
66
|
+
stepStarted("2026-01-02T10:03:00.000Z"),
|
|
67
|
+
],
|
|
68
|
+
createTurnTimestampCache(),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
72
|
+
new Date("2026-01-02T10:00:01.000Z"),
|
|
73
|
+
);
|
|
74
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
75
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
76
|
+
);
|
|
77
|
+
expect(timestamps.get("turn_other")?.user).toBe(undefined);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("resumes the scan at appended events and keeps the map identity when they teach nothing", () => {
|
|
81
|
+
const cache = createTurnTimestampCache();
|
|
82
|
+
const events = [
|
|
83
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
84
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
85
|
+
];
|
|
86
|
+
const first = collectTurnTimestamps(events, cache);
|
|
87
|
+
expect(first.get(TURN)?.assistant).toBe(undefined);
|
|
88
|
+
|
|
89
|
+
const quiet = [
|
|
90
|
+
...events,
|
|
91
|
+
turnStarted("2026-01-02T10:00:02.000Z", "turn_2"),
|
|
92
|
+
];
|
|
93
|
+
expect(collectTurnTimestamps(quiet, cache)).toBe(first);
|
|
94
|
+
|
|
95
|
+
const timestamps = collectTurnTimestamps(
|
|
96
|
+
[...quiet, stepStarted("2026-01-02T10:02:00.000Z")],
|
|
97
|
+
cache,
|
|
98
|
+
);
|
|
99
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
100
|
+
new Date("2026-01-02T10:00:01.000Z"),
|
|
101
|
+
);
|
|
102
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
103
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("stamps an assistant created by a turn ending before its first step", () => {
|
|
108
|
+
const timestamps = collectTurnTimestamps(
|
|
109
|
+
[
|
|
110
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
111
|
+
{
|
|
112
|
+
type: "turn.cancelled",
|
|
113
|
+
data: { sequence: 3, turnId: TURN },
|
|
114
|
+
meta: { at: "2026-01-02T10:00:05.000Z", id: "evt_cancel" },
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
createTurnTimestampCache(),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
121
|
+
new Date("2026-01-02T10:00:05.000Z"),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("stamps the assistant from the turn's first message-creating event when no step.started precedes it", () => {
|
|
126
|
+
const timestamps = collectTurnTimestamps(
|
|
127
|
+
[
|
|
128
|
+
turnStarted("2026-01-02T10:00:00.000Z"),
|
|
129
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
130
|
+
compactionRequested("2026-01-02T10:00:02.000Z"),
|
|
131
|
+
messageAppended("2026-01-02T10:02:00.000Z"),
|
|
132
|
+
],
|
|
133
|
+
createTurnTimestampCache(),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
137
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("skips malformed events without throwing", () => {
|
|
142
|
+
const events = [
|
|
143
|
+
{ type: "message.received", data: null, meta: { at: "x", id: "e1" } },
|
|
144
|
+
{ type: "step.started", meta: { at: "2026-01-01T00:00:00Z", id: "e2" } },
|
|
145
|
+
{
|
|
146
|
+
type: "message.received",
|
|
147
|
+
data: { turnId: 42 },
|
|
148
|
+
meta: { at: "x", id: "e3" },
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
type: "message.received",
|
|
152
|
+
data: { message: "hi", sequence: 2, turnId: TURN },
|
|
153
|
+
meta: { at: "not a date", id: "e4" },
|
|
154
|
+
},
|
|
155
|
+
{ type: "step.started", data: { stepIndex: 0, turnId: TURN } },
|
|
156
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
157
|
+
] as never as readonly MessageStreamEvent[];
|
|
158
|
+
|
|
159
|
+
const timestamps = collectTurnTimestamps(
|
|
160
|
+
events,
|
|
161
|
+
createTurnTimestampCache(),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
expect(timestamps.get(TURN)?.user).toBe(undefined);
|
|
165
|
+
expect(timestamps.get(TURN)?.assistant).toEqual(
|
|
166
|
+
new Date("2026-01-02T10:02:00.000Z"),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("re-derives from scratch when the log is replaced, so a recurring turn id serves the new session's stamps", () => {
|
|
171
|
+
const cache = createTurnTimestampCache();
|
|
172
|
+
collectTurnTimestamps(
|
|
173
|
+
[
|
|
174
|
+
messageReceived("2026-01-02T10:00:01.000Z"),
|
|
175
|
+
stepStarted("2026-01-02T10:02:00.000Z"),
|
|
176
|
+
],
|
|
177
|
+
cache,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const timestamps = collectTurnTimestamps(
|
|
181
|
+
[messageReceived("2026-03-01T09:00:01.000Z")],
|
|
182
|
+
cache,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
expect(timestamps.get(TURN)?.user).toEqual(
|
|
186
|
+
new Date("2026-03-01T09:00:01.000Z"),
|
|
187
|
+
);
|
|
188
|
+
expect(timestamps.get(TURN)?.assistant).toBe(undefined);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
2
|
+
|
|
3
|
+
export type TurnTimestamps = {
|
|
4
|
+
readonly user?: Date;
|
|
5
|
+
readonly assistant?: Date;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type TurnTimestampCache = {
|
|
9
|
+
lastEvents: readonly MessageStreamEvent[];
|
|
10
|
+
timestamps: ReadonlyMap<string, TurnTimestamps>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The reducer cases eve projects into a message: `message.received` creates
|
|
15
|
+
* `${turnId}:user`, and every assistant entry reaches `updateAssistantMessage`,
|
|
16
|
+
* which creates `${turnId}:assistant` for a turn that has none. It enumerates
|
|
17
|
+
* that reducer across the `>=0.32.0` peer range rather than stating a rule
|
|
18
|
+
* about turn ids, because `turn.started` and the compaction events carry one
|
|
19
|
+
* before the model runs. An event eve adds to the creating set later is absent
|
|
20
|
+
* here and falls back to the wall clock instead of stamping a wrong time.
|
|
21
|
+
*/
|
|
22
|
+
const ROLE_BY_EVENT_TYPE: Partial<
|
|
23
|
+
Record<MessageStreamEvent["type"], keyof TurnTimestamps>
|
|
24
|
+
> = {
|
|
25
|
+
"message.received": "user",
|
|
26
|
+
"step.started": "assistant",
|
|
27
|
+
"reasoning.appended": "assistant",
|
|
28
|
+
"reasoning.completed": "assistant",
|
|
29
|
+
"action.input.appended": "assistant",
|
|
30
|
+
"actions.requested": "assistant",
|
|
31
|
+
"input.requested": "assistant",
|
|
32
|
+
"action.result": "assistant",
|
|
33
|
+
"action.partial": "assistant",
|
|
34
|
+
"authorization.required": "assistant",
|
|
35
|
+
"authorization.completed": "assistant",
|
|
36
|
+
"message.appended": "assistant",
|
|
37
|
+
"message.completed": "assistant",
|
|
38
|
+
"result.completed": "assistant",
|
|
39
|
+
"turn.completed": "assistant",
|
|
40
|
+
"turn.cancelled": "assistant",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const createTurnTimestampCache = (): TurnTimestampCache => ({
|
|
44
|
+
lastEvents: [],
|
|
45
|
+
timestamps: new Map(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Eve appends to its log (`[...events, event]`), so a snapshot that keeps the
|
|
50
|
+
* previous scan's last element by identity shares the whole prefix and the
|
|
51
|
+
* scan resumes there. Any other snapshot is re-derived into a fresh map: turn
|
|
52
|
+
* ids are per-session sequence numbers that recur after `reset()`, so entries
|
|
53
|
+
* must not outlive the log that produced them. Re-derivation is also what
|
|
54
|
+
* makes the render-phase cache writes safe when React discards a render.
|
|
55
|
+
*/
|
|
56
|
+
export const collectTurnTimestamps = (
|
|
57
|
+
events: readonly MessageStreamEvent[],
|
|
58
|
+
cache: TurnTimestampCache,
|
|
59
|
+
): ReadonlyMap<string, TurnTimestamps> => {
|
|
60
|
+
if (events === cache.lastEvents) return cache.timestamps;
|
|
61
|
+
|
|
62
|
+
const scanned = cache.lastEvents;
|
|
63
|
+
const resumesScan =
|
|
64
|
+
scanned.length === 0 ||
|
|
65
|
+
events[scanned.length - 1] === scanned[scanned.length - 1];
|
|
66
|
+
|
|
67
|
+
let timestamps = cache.timestamps;
|
|
68
|
+
let draft: Map<string, TurnTimestamps> | undefined;
|
|
69
|
+
if (!resumesScan) {
|
|
70
|
+
draft = new Map();
|
|
71
|
+
timestamps = draft;
|
|
72
|
+
}
|
|
73
|
+
for (let i = resumesScan ? scanned.length : 0; i < events.length; i++) {
|
|
74
|
+
const event = events[i]!;
|
|
75
|
+
const role = ROLE_BY_EVENT_TYPE[event.type];
|
|
76
|
+
if (role === undefined) continue;
|
|
77
|
+
const data: unknown = (event as { readonly data?: unknown }).data;
|
|
78
|
+
if (typeof data !== "object" || data === null) continue;
|
|
79
|
+
const turnId = (data as { readonly turnId?: unknown }).turnId;
|
|
80
|
+
if (typeof turnId !== "string") continue;
|
|
81
|
+
const known = timestamps.get(turnId);
|
|
82
|
+
if (known?.[role] !== undefined) continue;
|
|
83
|
+
const at: unknown = (event as { readonly meta?: { readonly at?: unknown } })
|
|
84
|
+
.meta?.at;
|
|
85
|
+
if (typeof at !== "string") continue;
|
|
86
|
+
const date = new Date(at);
|
|
87
|
+
if (Number.isNaN(date.getTime())) continue;
|
|
88
|
+
draft ??= new Map(timestamps);
|
|
89
|
+
draft.set(turnId, { ...known, [role]: date });
|
|
90
|
+
timestamps = draft;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
cache.lastEvents = events;
|
|
94
|
+
cache.timestamps = timestamps;
|
|
95
|
+
return timestamps;
|
|
96
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
// Deliberately does not mock `eve/react`: the real `useEveAgent` resumes an
|
|
4
|
+
// offline session so the durable `meta.at` values travel through eve's own
|
|
5
|
+
// store and reducer before the adapter reads them. Supplying `session` keeps
|
|
6
|
+
// the store from constructing a network client.
|
|
7
|
+
|
|
8
|
+
import { renderHook, waitFor } from "@testing-library/react";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
11
|
+
|
|
12
|
+
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
13
|
+
|
|
14
|
+
const TURN = "turn_resumed";
|
|
15
|
+
|
|
16
|
+
const USER_AT = "2026-01-02T10:00:01.000Z";
|
|
17
|
+
const ASSISTANT_AT = "2026-01-02T10:02:00.000Z";
|
|
18
|
+
|
|
19
|
+
const resumedEvents = [
|
|
20
|
+
{
|
|
21
|
+
type: "turn.started",
|
|
22
|
+
data: { sequence: 1, turnId: TURN },
|
|
23
|
+
meta: { at: "2026-01-02T10:00:00.000Z", id: "evt_001" },
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
type: "message.received",
|
|
27
|
+
data: { message: "hi", sequence: 2, turnId: TURN },
|
|
28
|
+
meta: { at: USER_AT, id: "evt_101" },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
type: "step.started",
|
|
32
|
+
data: { modelId: "test-model", sequence: 3, stepIndex: 0, turnId: TURN },
|
|
33
|
+
meta: { at: ASSISTANT_AT, id: "evt_102" },
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
type: "message.completed",
|
|
37
|
+
data: {
|
|
38
|
+
finishReason: "stop",
|
|
39
|
+
message: "hello",
|
|
40
|
+
sequence: 5,
|
|
41
|
+
stepIndex: 0,
|
|
42
|
+
turnId: TURN,
|
|
43
|
+
},
|
|
44
|
+
meta: { at: "2026-01-02T10:02:06.000Z", id: "evt_003" },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "turn.completed",
|
|
48
|
+
data: { sequence: 6, turnId: TURN },
|
|
49
|
+
meta: { at: "2026-01-02T10:02:07.000Z", id: "evt_004" },
|
|
50
|
+
},
|
|
51
|
+
] as const satisfies readonly MessageStreamEvent[];
|
|
52
|
+
|
|
53
|
+
const offlineSession = {
|
|
54
|
+
state: { sessionId: "session_resumed", streamIndex: 0 },
|
|
55
|
+
cancel: async () => ({ status: "no_active_turn" }),
|
|
56
|
+
send: () => {
|
|
57
|
+
throw new Error("the resume test must not reach the network");
|
|
58
|
+
},
|
|
59
|
+
stream: async function* () {
|
|
60
|
+
yield* JSON.parse(
|
|
61
|
+
JSON.stringify(resumedEvents),
|
|
62
|
+
) as readonly MessageStreamEvent[];
|
|
63
|
+
},
|
|
64
|
+
} as never;
|
|
65
|
+
|
|
66
|
+
describe("useEveAgentRuntime against a resumed eve session", () => {
|
|
67
|
+
it("renders resumed history at its durable event times, not the current time", async () => {
|
|
68
|
+
const { result } = renderHook(() =>
|
|
69
|
+
useEveAgentRuntime({ resume: true, session: offlineSession }),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await waitFor(() =>
|
|
73
|
+
expect(result.current.thread.getState().messages).toHaveLength(2),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const messages = result.current.thread.getState().messages;
|
|
77
|
+
expect(messages[0]?.createdAt).toEqual(new Date(USER_AT));
|
|
78
|
+
expect(messages[1]?.createdAt).toEqual(new Date(ASSISTANT_AT));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -18,6 +18,7 @@ vi.mock("eve/react", async (importOriginal) => ({
|
|
|
18
18
|
useEveAgent: mockUseEveAgent,
|
|
19
19
|
}));
|
|
20
20
|
|
|
21
|
+
import type { MessageStreamEvent } from "eve/client";
|
|
21
22
|
import type { EveMessageData } from "eve/react";
|
|
22
23
|
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
23
24
|
import { eveExtras } from "./eveExtras";
|
|
@@ -2152,3 +2153,88 @@ describe("useEveAgentRuntime thread refetch", () => {
|
|
|
2152
2153
|
});
|
|
2153
2154
|
});
|
|
2154
2155
|
});
|
|
2156
|
+
|
|
2157
|
+
describe("useEveAgentRuntime createdAt derivation", () => {
|
|
2158
|
+
const TURN = "turn_ts";
|
|
2159
|
+
const USER_AT = "2026-01-02T10:00:01.000Z";
|
|
2160
|
+
const ASSISTANT_AT = "2026-01-02T10:02:00.000Z";
|
|
2161
|
+
|
|
2162
|
+
const turnEvents = [
|
|
2163
|
+
{
|
|
2164
|
+
type: "turn.started",
|
|
2165
|
+
data: { sequence: 1, turnId: TURN },
|
|
2166
|
+
meta: { at: "2026-01-02T10:00:00.000Z", id: "evt_1" },
|
|
2167
|
+
},
|
|
2168
|
+
{
|
|
2169
|
+
type: "message.received",
|
|
2170
|
+
data: { message: "hi", sequence: 2, turnId: TURN },
|
|
2171
|
+
meta: { at: USER_AT, id: "evt_2" },
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
type: "step.started",
|
|
2175
|
+
data: { modelId: "m", sequence: 3, stepIndex: 0, turnId: TURN },
|
|
2176
|
+
meta: { at: ASSISTANT_AT, id: "evt_3" },
|
|
2177
|
+
},
|
|
2178
|
+
] satisfies readonly MessageStreamEvent[];
|
|
2179
|
+
|
|
2180
|
+
const turnData: EveMessageData = {
|
|
2181
|
+
messages: [
|
|
2182
|
+
{
|
|
2183
|
+
id: `${TURN}:user`,
|
|
2184
|
+
role: "user",
|
|
2185
|
+
metadata: { turnId: TURN },
|
|
2186
|
+
parts: [{ type: "text", text: "hi" }],
|
|
2187
|
+
},
|
|
2188
|
+
{
|
|
2189
|
+
id: `${TURN}:assistant`,
|
|
2190
|
+
role: "assistant",
|
|
2191
|
+
metadata: { turnId: TURN },
|
|
2192
|
+
parts: [{ type: "text", text: "hello" }],
|
|
2193
|
+
},
|
|
2194
|
+
],
|
|
2195
|
+
};
|
|
2196
|
+
|
|
2197
|
+
it("stamps a message with the wall clock when no event covers its turn, and keeps that stamp across renders", () => {
|
|
2198
|
+
const before = Date.now();
|
|
2199
|
+
const agent = createAgent({ data: turnData, events: [] });
|
|
2200
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
2201
|
+
|
|
2202
|
+
const { result, rerender } = renderHook(() => useEveAgentRuntime());
|
|
2203
|
+
|
|
2204
|
+
const stamped = result.current.thread.getState().messages[0]?.createdAt;
|
|
2205
|
+
expect(stamped?.getTime()).toBeGreaterThanOrEqual(before);
|
|
2206
|
+
|
|
2207
|
+
mockUseEveAgent.mockReturnValue({
|
|
2208
|
+
...agent,
|
|
2209
|
+
data: { messages: [...turnData.messages] },
|
|
2210
|
+
} as never);
|
|
2211
|
+
rerender();
|
|
2212
|
+
|
|
2213
|
+
expect(result.current.thread.getState().messages[0]?.createdAt).toBe(
|
|
2214
|
+
stamped,
|
|
2215
|
+
);
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it("keeps the message list identity when appended events stamp no message", () => {
|
|
2219
|
+
const agent = createAgent({ data: turnData, events: turnEvents });
|
|
2220
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
2221
|
+
|
|
2222
|
+
const { result, rerender } = renderHook(() => useEveAgentRuntime());
|
|
2223
|
+
const first = result.current.thread.getState().messages;
|
|
2224
|
+
|
|
2225
|
+
mockUseEveAgent.mockReturnValue({
|
|
2226
|
+
...agent,
|
|
2227
|
+
events: [
|
|
2228
|
+
...turnEvents,
|
|
2229
|
+
{
|
|
2230
|
+
type: "turn.started",
|
|
2231
|
+
data: { sequence: 4, turnId: "turn_next" },
|
|
2232
|
+
meta: { at: "2026-01-02T10:05:00.000Z", id: "evt_4" },
|
|
2233
|
+
},
|
|
2234
|
+
],
|
|
2235
|
+
} as never);
|
|
2236
|
+
rerender();
|
|
2237
|
+
|
|
2238
|
+
expect(result.current.thread.getState().messages).toBe(first);
|
|
2239
|
+
});
|
|
2240
|
+
});
|
|
@@ -39,6 +39,10 @@ import {
|
|
|
39
39
|
getEveMessageContent,
|
|
40
40
|
toEveInputResponse,
|
|
41
41
|
} from "./convertEveMessages";
|
|
42
|
+
import {
|
|
43
|
+
collectTurnTimestamps,
|
|
44
|
+
createTurnTimestampCache,
|
|
45
|
+
} from "./deriveCreatedAt";
|
|
42
46
|
import { eveExtras } from "./eveExtras";
|
|
43
47
|
|
|
44
48
|
const USER_STAGED_STATUS = {
|
|
@@ -211,6 +215,14 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
211
215
|
agent.status === "submitted" || agent.status === "streaming";
|
|
212
216
|
const isRunning = providerIsRunning || hasExecutingTools;
|
|
213
217
|
|
|
218
|
+
// Kept apart from the message memo so events that teach no timestamp keep
|
|
219
|
+
// the map identity and skip rebuilding every ThreadMessage.
|
|
220
|
+
const turnTimestampCacheRef = useRef(createTurnTimestampCache());
|
|
221
|
+
const turnTimestamps = useMemo(
|
|
222
|
+
() => collectTurnTimestamps(agent.events, turnTimestampCacheRef.current),
|
|
223
|
+
[agent.events],
|
|
224
|
+
);
|
|
225
|
+
|
|
214
226
|
const convertedMessages = useMemo(() => {
|
|
215
227
|
const createdAtByMessageId = createdAtByMessageIdRef.current;
|
|
216
228
|
const messageIds = new Set(
|
|
@@ -224,6 +236,13 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
224
236
|
isRunning,
|
|
225
237
|
error: agent.error,
|
|
226
238
|
getCreatedAt: (message) => {
|
|
239
|
+
const turnId = message.metadata?.turnId;
|
|
240
|
+
const durable =
|
|
241
|
+
turnId === undefined
|
|
242
|
+
? undefined
|
|
243
|
+
: turnTimestamps.get(turnId)?.[message.role];
|
|
244
|
+
if (durable !== undefined) return durable;
|
|
245
|
+
|
|
227
246
|
const existing = createdAtByMessageId.get(message.id);
|
|
228
247
|
if (existing) return existing;
|
|
229
248
|
|
|
@@ -232,7 +251,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
232
251
|
return createdAt;
|
|
233
252
|
},
|
|
234
253
|
});
|
|
235
|
-
}, [agent.data, agent.error, isRunning]);
|
|
254
|
+
}, [agent.data, agent.error, isRunning, turnTimestamps]);
|
|
236
255
|
|
|
237
256
|
const messages = stagedMessages ?? convertedMessages;
|
|
238
257
|
const messagesRef = useRef(messages);
|