@assistant-ui/eve 0.0.15 → 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.
@@ -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
+ });
@@ -1,6 +1,12 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
- import { act, renderHook, waitFor } from "@testing-library/react";
3
+ import { act, render, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ startTransition,
6
+ Suspense,
7
+ useLayoutEffect,
8
+ type PropsWithChildren,
9
+ } from "react";
4
10
  import { afterEach, describe, expect, it, vi } from "vitest";
5
11
 
6
12
  const { mockUseEveAgent } = vi.hoisted(() => ({
@@ -12,6 +18,7 @@ vi.mock("eve/react", async (importOriginal) => ({
12
18
  useEveAgent: mockUseEveAgent,
13
19
  }));
14
20
 
21
+ import type { MessageStreamEvent } from "eve/client";
15
22
  import type { EveMessageData } from "eve/react";
16
23
  import { useEveAgentRuntime } from "./useEveAgentRuntime";
17
24
  import { eveExtras } from "./eveExtras";
@@ -388,9 +395,9 @@ describe("useEveAgentRuntime tool approval responses", () => {
388
395
  try {
389
396
  const { result } = renderHook(() => useEveAgentRuntime());
390
397
 
391
- expect(() => respondToTextRequest(result, { approved: true })).toThrow(
392
- /was not answered by this response/,
393
- );
398
+ await expect(
399
+ respondToTextRequest(result, { approved: true }),
400
+ ).rejects.toThrow(/was not answered by this response/);
394
401
 
395
402
  await flushMicrotasks();
396
403
  await flushMicrotasks();
@@ -1205,6 +1212,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1205
1212
  mockUseEveAgent.mockReturnValue(agent as never);
1206
1213
  const { result, rerender } = renderHook(() => useEveAgentRuntime());
1207
1214
 
1215
+ let humanRequests = 0;
1208
1216
  const humanRejections: unknown[] = [];
1209
1217
  act(() => {
1210
1218
  result.current.registerModelContextProvider({
@@ -1216,6 +1224,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1216
1224
  _args: unknown,
1217
1225
  context: { human: (payload: unknown) => Promise<unknown> },
1218
1226
  ) => {
1227
+ humanRequests += 1;
1219
1228
  try {
1220
1229
  return await context.human({ request: "approve" });
1221
1230
  } catch (error) {
@@ -1235,7 +1244,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1235
1244
  rerender();
1236
1245
 
1237
1246
  await waitFor(() => {
1238
- expect(result.current.thread.getState().isRunning).toBe(true);
1247
+ expect(humanRequests).toBe(2);
1239
1248
  });
1240
1249
 
1241
1250
  act(() => {
@@ -1817,6 +1826,131 @@ describe("useEveAgentRuntime cancel binding", () => {
1817
1826
  describe("useEveAgentRuntime thread refetch", () => {
1818
1827
  const session = { sessionId: "s1" };
1819
1828
 
1829
+ const mockWorkspaceAgents = () => {
1830
+ const resumeA = vi.fn().mockResolvedValue(undefined);
1831
+ const resumeB = vi.fn().mockResolvedValue(undefined);
1832
+ const agentA = createAgent({
1833
+ data: {
1834
+ messages: [
1835
+ {
1836
+ id: "workspace-a",
1837
+ role: "user",
1838
+ parts: [{ type: "text", text: "workspace A" }],
1839
+ },
1840
+ ],
1841
+ },
1842
+ session,
1843
+ resume: resumeA,
1844
+ });
1845
+ const agentB = createAgent({
1846
+ data: {
1847
+ messages: [
1848
+ {
1849
+ id: "workspace-b",
1850
+ role: "user",
1851
+ parts: [{ type: "text", text: "workspace B" }],
1852
+ },
1853
+ ],
1854
+ },
1855
+ session,
1856
+ resume: resumeB,
1857
+ });
1858
+ mockUseEveAgent.mockImplementation((options) =>
1859
+ (options as { workspace: string }).workspace === "A"
1860
+ ? (agentA as never)
1861
+ : (agentB as never),
1862
+ );
1863
+ return { agentA, agentB, resumeA, resumeB };
1864
+ };
1865
+
1866
+ it("publishes committed state before descendant layout effects", async () => {
1867
+ const { resumeA, resumeB } = mockWorkspaceAgents();
1868
+
1869
+ let refetch: Promise<void> | undefined;
1870
+ let currentRuntime: ReturnType<typeof useEveAgentRuntime> | undefined;
1871
+ const RefetchOnLayout = ({
1872
+ runtime,
1873
+ enabled,
1874
+ }: {
1875
+ runtime: ReturnType<typeof useEveAgentRuntime>;
1876
+ enabled: boolean;
1877
+ }) => {
1878
+ useLayoutEffect(() => {
1879
+ if (!enabled) return;
1880
+ runtime.thread.append({
1881
+ role: "user",
1882
+ content: [{ type: "text", text: "draft from B" }],
1883
+ startRun: false,
1884
+ });
1885
+ refetch = runtime.threads.reloadMainThread();
1886
+ }, [enabled, runtime]);
1887
+ return null;
1888
+ };
1889
+ const Probe = ({
1890
+ workspace,
1891
+ refetchOnLayout,
1892
+ }: {
1893
+ workspace: string;
1894
+ refetchOnLayout: boolean;
1895
+ }) => {
1896
+ const runtime = useEveAgentRuntime({ workspace } as never);
1897
+ currentRuntime = runtime;
1898
+ return <RefetchOnLayout runtime={runtime} enabled={refetchOnLayout} />;
1899
+ };
1900
+
1901
+ const view = render(<Probe workspace="A" refetchOnLayout={false} />);
1902
+ view.rerender(<Probe workspace="B" refetchOnLayout />);
1903
+ await act(async () => {
1904
+ await refetch;
1905
+ });
1906
+
1907
+ expect(resumeA).not.toHaveBeenCalled();
1908
+ expect(resumeB).toHaveBeenCalledTimes(1);
1909
+ expect(getText(currentRuntime!)).toEqual(["workspace B", "draft from B"]);
1910
+ view.unmount();
1911
+ });
1912
+
1913
+ it("keeps refetches scoped to the committed agent", async () => {
1914
+ const { agentB, resumeA, resumeB } = mockWorkspaceAgents();
1915
+
1916
+ const pending = new Promise<never>(() => {});
1917
+ let blocked = false;
1918
+ const Blocker = () => {
1919
+ if (blocked) throw pending;
1920
+ return null;
1921
+ };
1922
+ const Wrapper = ({ children }: PropsWithChildren) => (
1923
+ <Suspense fallback={null}>
1924
+ {children}
1925
+ <Blocker />
1926
+ </Suspense>
1927
+ );
1928
+
1929
+ const { result, rerender } = renderHook(
1930
+ ({ workspace }) => useEveAgentRuntime({ workspace } as never),
1931
+ {
1932
+ initialProps: { workspace: "A" },
1933
+ wrapper: Wrapper,
1934
+ },
1935
+ );
1936
+
1937
+ act(() => {
1938
+ blocked = true;
1939
+ startTransition(() => rerender({ workspace: "B" }));
1940
+ });
1941
+ expect(mockUseEveAgent.mock.results.at(-1)?.value).toBe(agentB);
1942
+
1943
+ await act(async () => {
1944
+ await result.current.threads.reloadMainThread();
1945
+ });
1946
+
1947
+ expect(resumeB).not.toHaveBeenCalled();
1948
+ expect(resumeA).toHaveBeenCalledTimes(1);
1949
+
1950
+ await stageMessage(result.current, "draft from A");
1951
+ expect(getText(result.current)).toEqual(["workspace A", "draft from A"]);
1952
+ });
1953
+
1820
1954
  it("replays the session through eve resume when the thread is refetched", async () => {
1821
1955
  const resume = vi.fn().mockResolvedValue(undefined);
1822
1956
  const agent = createAgent({ session, resume });
@@ -2019,3 +2153,88 @@ describe("useEveAgentRuntime thread refetch", () => {
2019
2153
  });
2020
2154
  });
2021
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
+ });
@@ -1,6 +1,13 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ useCallback,
5
+ useEffect,
6
+ useInsertionEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
4
11
  import {
5
12
  fromThreadMessageLike,
6
13
  generateId,
@@ -32,6 +39,10 @@ import {
32
39
  getEveMessageContent,
33
40
  toEveInputResponse,
34
41
  } from "./convertEveMessages";
42
+ import {
43
+ collectTurnTimestamps,
44
+ createTurnTimestampCache,
45
+ } from "./deriveCreatedAt";
35
46
  import { eveExtras } from "./eveExtras";
36
47
 
37
48
  const USER_STAGED_STATUS = {
@@ -204,6 +215,14 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
204
215
  agent.status === "submitted" || agent.status === "streaming";
205
216
  const isRunning = providerIsRunning || hasExecutingTools;
206
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
+
207
226
  const convertedMessages = useMemo(() => {
208
227
  const createdAtByMessageId = createdAtByMessageIdRef.current;
209
228
  const messageIds = new Set(
@@ -217,6 +236,13 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
217
236
  isRunning,
218
237
  error: agent.error,
219
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
+
220
246
  const existing = createdAtByMessageId.get(message.id);
221
247
  if (existing) return existing;
222
248
 
@@ -225,13 +251,17 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
225
251
  return createdAt;
226
252
  },
227
253
  });
228
- }, [agent.data, agent.error, isRunning]);
254
+ }, [agent.data, agent.error, isRunning, turnTimestamps]);
229
255
 
230
256
  const messages = stagedMessages ?? convertedMessages;
231
257
  const messagesRef = useRef(messages);
232
- messagesRef.current = messages;
233
258
  const agentRef = useRef(agent);
234
- agentRef.current = agent;
259
+ // Descendant layout effects can dispatch against these refs synchronously;
260
+ // this matches useA2ARuntime's commit-scoped ref publication.
261
+ useInsertionEffect(() => {
262
+ messagesRef.current = messages;
263
+ agentRef.current = agent;
264
+ }, [agent, messages]);
235
265
 
236
266
  // Upstream `EveAgentStore` `send` and `respond` reject while a turn is in
237
267
  // flight and only resolve once the turn's stream parks, so a pending chain
@@ -464,10 +494,11 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
464
494
  }
465
495
  : {}),
466
496
  onRespondToToolApproval: (response) => {
467
- // Eve leaves an unanswered request pending, so an unmappable response
468
- // must stay answerable. Mapping before the first await lets the mapper's
469
- // own error surface synchronously to the caller that rendered the
470
- // controls, the only signal the void `respondToApproval` seam carries.
497
+ // Eve resolves a request the moment any response for it arrives, and an
498
+ // empty one is recorded as an answer with no content. Mapping before the
499
+ // send is enqueued keeps an unmappable response unsent, so the request
500
+ // stays answerable; the mapper's error reaches the caller as the seam's
501
+ // rejection.
471
502
  const inputResponse = toEveInputResponse(
472
503
  response,
473
504
  findEveInputRequest(agent.data, response.approvalId),