@assistant-ui/eve 0.0.14 → 0.0.16

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,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(() => ({
@@ -328,6 +334,163 @@ describe("useEveAgentRuntime status forwarding", () => {
328
334
  });
329
335
  });
330
336
 
337
+ describe("useEveAgentRuntime tool approval responses", () => {
338
+ const textRequestData: EveMessageData = {
339
+ messages: [
340
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
341
+ {
342
+ id: "a1",
343
+ role: "assistant",
344
+ parts: [
345
+ {
346
+ type: "dynamic-tool",
347
+ state: "approval-requested",
348
+ toolCallId: "call_1",
349
+ toolName: "ask_question",
350
+ input: {},
351
+ approval: { id: "req_1" },
352
+ toolMetadata: {
353
+ eve: {
354
+ kind: "tool-call",
355
+ name: "ask_question",
356
+ inputRequest: {
357
+ requestId: "req_1",
358
+ prompt: "What should the subject line be?",
359
+ kind: "question",
360
+ display: "text",
361
+ },
362
+ },
363
+ },
364
+ },
365
+ ],
366
+ },
367
+ ],
368
+ };
369
+
370
+ const flushMicrotasks = () =>
371
+ new Promise((resolve) => setTimeout(resolve, 0));
372
+
373
+ const processEvents = process as unknown as {
374
+ on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
375
+ off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
376
+ };
377
+
378
+ const respondToTextRequest = (
379
+ result: { current: ReturnType<typeof useEveAgentRuntime> },
380
+ response: { approved: boolean; reason?: string; optionId?: string },
381
+ ) =>
382
+ result.current.thread
383
+ .getMessageById("a1")
384
+ .getMessagePartByToolCallId("call_1")
385
+ .respondToToolApproval(response);
386
+
387
+ it("keeps an unanswered free-form request pending through the default controls", async () => {
388
+ const rejections: unknown[] = [];
389
+ const onUnhandledRejection = (reason: unknown) => rejections.push(reason);
390
+ processEvents.on("unhandledRejection", onUnhandledRejection);
391
+ const agent = createAgent({ data: textRequestData });
392
+ mockUseEveAgent.mockReturnValue(agent as never);
393
+
394
+ try {
395
+ const { result } = renderHook(() => useEveAgentRuntime());
396
+
397
+ await expect(
398
+ respondToTextRequest(result, { approved: true }),
399
+ ).rejects.toThrow(/was not answered by this response/);
400
+
401
+ await flushMicrotasks();
402
+ await flushMicrotasks();
403
+
404
+ expect(agent.send).not.toHaveBeenCalled();
405
+ expect(agent.respond).not.toHaveBeenCalled();
406
+ expect(rejections).toEqual([]);
407
+ } finally {
408
+ processEvents.off("unhandledRejection", onUnhandledRejection);
409
+ }
410
+ });
411
+
412
+ const bareApprovalData = (toolMetadata?: {
413
+ eve: { kind: "tool-call"; name: string };
414
+ }): EveMessageData => ({
415
+ messages: [
416
+ { id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
417
+ {
418
+ id: "a1",
419
+ role: "assistant",
420
+ parts: [
421
+ {
422
+ type: "dynamic-tool",
423
+ state: "approval-requested",
424
+ toolCallId: "call_1",
425
+ toolName: "delete_file",
426
+ input: {},
427
+ approval: { id: "req_1" },
428
+ ...(toolMetadata && { toolMetadata }),
429
+ },
430
+ ],
431
+ },
432
+ ],
433
+ });
434
+
435
+ it.each([
436
+ ["no eve tool metadata at all", undefined],
437
+ [
438
+ "eve metadata carrying no input request",
439
+ { eve: { kind: "tool-call", name: "delete_file" } },
440
+ ],
441
+ ])(
442
+ "still answers an ordinary approval when the part has %s",
443
+ async (_label, toolMetadata) => {
444
+ const agent = createAgent({
445
+ data: bareApprovalData(toolMetadata as never),
446
+ });
447
+ mockUseEveAgent.mockReturnValue(agent as never);
448
+
449
+ const { result } = renderHook(() => useEveAgentRuntime());
450
+ expect(() =>
451
+ respondToTextRequest(result, { approved: true }),
452
+ ).not.toThrow();
453
+
454
+ await flushMicrotasks();
455
+
456
+ expect(agent.respond).toHaveBeenCalledWith([
457
+ { requestId: "req_1", optionId: "approve" },
458
+ ]);
459
+ },
460
+ );
461
+
462
+ it("maps a refusal on a request the data does not carry", async () => {
463
+ const agent = createAgent({ data: bareApprovalData(undefined) });
464
+ mockUseEveAgent.mockReturnValue(agent as never);
465
+
466
+ const { result } = renderHook(() => useEveAgentRuntime());
467
+ respondToTextRequest(result, { approved: false, reason: "not safe" });
468
+
469
+ await flushMicrotasks();
470
+
471
+ expect(agent.respond).toHaveBeenCalledWith([
472
+ { requestId: "req_1", optionId: "cancel", text: "not safe" },
473
+ ]);
474
+ });
475
+
476
+ it("submits a free-form answer as text without an option id", async () => {
477
+ const agent = createAgent({ data: textRequestData });
478
+ mockUseEveAgent.mockReturnValue(agent as never);
479
+
480
+ const { result } = renderHook(() => useEveAgentRuntime());
481
+ respondToTextRequest(result, {
482
+ approved: true,
483
+ reason: "Quarterly results",
484
+ });
485
+
486
+ await flushMicrotasks();
487
+
488
+ expect(agent.respond).toHaveBeenCalledWith([
489
+ { requestId: "req_1", text: "Quarterly results" },
490
+ ]);
491
+ });
492
+ });
493
+
331
494
  const settledData: EveMessageData = {
332
495
  messages: [
333
496
  { id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
@@ -1048,6 +1211,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1048
1211
  mockUseEveAgent.mockReturnValue(agent as never);
1049
1212
  const { result, rerender } = renderHook(() => useEveAgentRuntime());
1050
1213
 
1214
+ let humanRequests = 0;
1051
1215
  const humanRejections: unknown[] = [];
1052
1216
  act(() => {
1053
1217
  result.current.registerModelContextProvider({
@@ -1059,6 +1223,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1059
1223
  _args: unknown,
1060
1224
  context: { human: (payload: unknown) => Promise<unknown> },
1061
1225
  ) => {
1226
+ humanRequests += 1;
1062
1227
  try {
1063
1228
  return await context.human({ request: "approve" });
1064
1229
  } catch (error) {
@@ -1078,7 +1243,7 @@ describe("useEveAgentRuntime extras wiring", () => {
1078
1243
  rerender();
1079
1244
 
1080
1245
  await waitFor(() => {
1081
- expect(result.current.thread.getState().isRunning).toBe(true);
1246
+ expect(humanRequests).toBe(2);
1082
1247
  });
1083
1248
 
1084
1249
  act(() => {
@@ -1580,3 +1745,410 @@ describe("useEveAgentRuntime concurrent sends", () => {
1580
1745
  ]);
1581
1746
  });
1582
1747
  });
1748
+
1749
+ describe("useEveAgentRuntime cancel binding", () => {
1750
+ it("cancels through the eve 0.38+ cancel binding when the agent provides it", async () => {
1751
+ const cancel = vi.fn().mockResolvedValue({ status: "cancelled" });
1752
+ const agent = createAgent({ status: "streaming", cancel });
1753
+ mockUseEveAgent.mockReturnValue(agent as never);
1754
+ const { result } = renderHook(() => useEveAgentRuntime());
1755
+
1756
+ await act(async () => {
1757
+ result.current.thread.cancelRun();
1758
+ });
1759
+
1760
+ expect(cancel).toHaveBeenCalledTimes(1);
1761
+ expect(agent.stop).not.toHaveBeenCalled();
1762
+ });
1763
+
1764
+ it("falls back to the pre-0.38 stop binding when cancel is absent", async () => {
1765
+ const agent = createAgent({ status: "streaming" });
1766
+ mockUseEveAgent.mockReturnValue(agent as never);
1767
+ const { result } = renderHook(() => useEveAgentRuntime());
1768
+
1769
+ await act(async () => {
1770
+ result.current.thread.cancelRun();
1771
+ });
1772
+
1773
+ expect(agent.stop).toHaveBeenCalledTimes(1);
1774
+ });
1775
+
1776
+ it("drops a queued send when the durable cancel settles late", async () => {
1777
+ let resolveFirstSend!: () => void;
1778
+ const send = vi
1779
+ .fn()
1780
+ .mockImplementationOnce(
1781
+ () =>
1782
+ new Promise<void>((resolve) => {
1783
+ resolveFirstSend = resolve;
1784
+ }),
1785
+ )
1786
+ .mockResolvedValue(undefined);
1787
+ let resolveCancel!: () => void;
1788
+ const cancel = vi.fn(
1789
+ () =>
1790
+ new Promise<{ status: string }>((resolve) => {
1791
+ resolveCancel = () => resolve({ status: "cancelled" });
1792
+ }),
1793
+ );
1794
+ const agent = createAgent({ data: settledData, send, cancel });
1795
+ mockUseEveAgent.mockReturnValue(agent as never);
1796
+ const { result } = renderHook(() => useEveAgentRuntime());
1797
+
1798
+ await act(async () => {
1799
+ result.current.thread.append({
1800
+ role: "user",
1801
+ content: [{ type: "text", text: "first" }],
1802
+ });
1803
+ });
1804
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
1805
+
1806
+ await act(async () => {
1807
+ result.current.thread.append({
1808
+ role: "user",
1809
+ content: [{ type: "text", text: "queued" }],
1810
+ });
1811
+ });
1812
+ act(() => {
1813
+ result.current.thread.cancelRun();
1814
+ });
1815
+ expect(cancel).toHaveBeenCalledTimes(1);
1816
+
1817
+ await act(async () => {
1818
+ resolveFirstSend();
1819
+ resolveCancel();
1820
+ });
1821
+ expect(send).toHaveBeenCalledTimes(1);
1822
+ });
1823
+ });
1824
+
1825
+ describe("useEveAgentRuntime thread refetch", () => {
1826
+ const session = { sessionId: "s1" };
1827
+
1828
+ const mockWorkspaceAgents = () => {
1829
+ const resumeA = vi.fn().mockResolvedValue(undefined);
1830
+ const resumeB = vi.fn().mockResolvedValue(undefined);
1831
+ const agentA = createAgent({
1832
+ data: {
1833
+ messages: [
1834
+ {
1835
+ id: "workspace-a",
1836
+ role: "user",
1837
+ parts: [{ type: "text", text: "workspace A" }],
1838
+ },
1839
+ ],
1840
+ },
1841
+ session,
1842
+ resume: resumeA,
1843
+ });
1844
+ const agentB = createAgent({
1845
+ data: {
1846
+ messages: [
1847
+ {
1848
+ id: "workspace-b",
1849
+ role: "user",
1850
+ parts: [{ type: "text", text: "workspace B" }],
1851
+ },
1852
+ ],
1853
+ },
1854
+ session,
1855
+ resume: resumeB,
1856
+ });
1857
+ mockUseEveAgent.mockImplementation((options) =>
1858
+ (options as { workspace: string }).workspace === "A"
1859
+ ? (agentA as never)
1860
+ : (agentB as never),
1861
+ );
1862
+ return { agentA, agentB, resumeA, resumeB };
1863
+ };
1864
+
1865
+ it("publishes committed state before descendant layout effects", async () => {
1866
+ const { resumeA, resumeB } = mockWorkspaceAgents();
1867
+
1868
+ let refetch: Promise<void> | undefined;
1869
+ let currentRuntime: ReturnType<typeof useEveAgentRuntime> | undefined;
1870
+ const RefetchOnLayout = ({
1871
+ runtime,
1872
+ enabled,
1873
+ }: {
1874
+ runtime: ReturnType<typeof useEveAgentRuntime>;
1875
+ enabled: boolean;
1876
+ }) => {
1877
+ useLayoutEffect(() => {
1878
+ if (!enabled) return;
1879
+ runtime.thread.append({
1880
+ role: "user",
1881
+ content: [{ type: "text", text: "draft from B" }],
1882
+ startRun: false,
1883
+ });
1884
+ refetch = runtime.threads.reloadMainThread();
1885
+ }, [enabled, runtime]);
1886
+ return null;
1887
+ };
1888
+ const Probe = ({
1889
+ workspace,
1890
+ refetchOnLayout,
1891
+ }: {
1892
+ workspace: string;
1893
+ refetchOnLayout: boolean;
1894
+ }) => {
1895
+ const runtime = useEveAgentRuntime({ workspace } as never);
1896
+ currentRuntime = runtime;
1897
+ return <RefetchOnLayout runtime={runtime} enabled={refetchOnLayout} />;
1898
+ };
1899
+
1900
+ const view = render(<Probe workspace="A" refetchOnLayout={false} />);
1901
+ view.rerender(<Probe workspace="B" refetchOnLayout />);
1902
+ await act(async () => {
1903
+ await refetch;
1904
+ });
1905
+
1906
+ expect(resumeA).not.toHaveBeenCalled();
1907
+ expect(resumeB).toHaveBeenCalledTimes(1);
1908
+ expect(getText(currentRuntime!)).toEqual(["workspace B", "draft from B"]);
1909
+ view.unmount();
1910
+ });
1911
+
1912
+ it("keeps refetches scoped to the committed agent", async () => {
1913
+ const { agentB, resumeA, resumeB } = mockWorkspaceAgents();
1914
+
1915
+ const pending = new Promise<never>(() => {});
1916
+ let blocked = false;
1917
+ const Blocker = () => {
1918
+ if (blocked) throw pending;
1919
+ return null;
1920
+ };
1921
+ const Wrapper = ({ children }: PropsWithChildren) => (
1922
+ <Suspense fallback={null}>
1923
+ {children}
1924
+ <Blocker />
1925
+ </Suspense>
1926
+ );
1927
+
1928
+ const { result, rerender } = renderHook(
1929
+ ({ workspace }) => useEveAgentRuntime({ workspace } as never),
1930
+ {
1931
+ initialProps: { workspace: "A" },
1932
+ wrapper: Wrapper,
1933
+ },
1934
+ );
1935
+
1936
+ act(() => {
1937
+ blocked = true;
1938
+ startTransition(() => rerender({ workspace: "B" }));
1939
+ });
1940
+ expect(mockUseEveAgent.mock.results.at(-1)?.value).toBe(agentB);
1941
+
1942
+ await act(async () => {
1943
+ await result.current.threads.reloadMainThread();
1944
+ });
1945
+
1946
+ expect(resumeB).not.toHaveBeenCalled();
1947
+ expect(resumeA).toHaveBeenCalledTimes(1);
1948
+
1949
+ await stageMessage(result.current, "draft from A");
1950
+ expect(getText(result.current)).toEqual(["workspace A", "draft from A"]);
1951
+ });
1952
+
1953
+ it("replays the session through eve resume when the thread is refetched", async () => {
1954
+ const resume = vi.fn().mockResolvedValue(undefined);
1955
+ const agent = createAgent({ session, resume });
1956
+ mockUseEveAgent.mockReturnValue(agent as never);
1957
+ const { result } = renderHook(() => useEveAgentRuntime());
1958
+ expect(result.current.thread.getState().capabilities.refetchThread).toBe(
1959
+ true,
1960
+ );
1961
+
1962
+ await act(async () => {
1963
+ await result.current.threads.reloadMainThread();
1964
+ });
1965
+
1966
+ expect(resume).toHaveBeenCalledTimes(1);
1967
+ });
1968
+
1969
+ it("propagates a failed replay to the refetch caller", async () => {
1970
+ const resume = vi.fn().mockRejectedValue(new Error("replay failed"));
1971
+ const agent = createAgent({ session, resume });
1972
+ mockUseEveAgent.mockReturnValue(agent as never);
1973
+ const { result } = renderHook(() => useEveAgentRuntime());
1974
+
1975
+ await act(async () => {
1976
+ await expect(result.current.threads.reloadMainThread()).rejects.toThrow(
1977
+ "replay failed",
1978
+ );
1979
+ });
1980
+ });
1981
+
1982
+ it("resolves without replaying when no session exists yet", async () => {
1983
+ const resume = vi.fn();
1984
+ const agent = createAgent({ session: undefined, resume });
1985
+ mockUseEveAgent.mockReturnValue(agent as never);
1986
+ const { result } = renderHook(() => useEveAgentRuntime());
1987
+
1988
+ await act(async () => {
1989
+ await result.current.threads.reloadMainThread();
1990
+ });
1991
+
1992
+ expect(resume).not.toHaveBeenCalled();
1993
+ });
1994
+
1995
+ it("waits for a turn dispatched before it to park, then replays", async () => {
1996
+ let resolveSend!: () => void;
1997
+ const send = vi.fn(
1998
+ () =>
1999
+ new Promise<void>((resolve) => {
2000
+ resolveSend = resolve;
2001
+ }),
2002
+ );
2003
+ const resume = vi.fn().mockResolvedValue(undefined);
2004
+ const agent = createAgent({ session, resume, send });
2005
+ mockUseEveAgent.mockReturnValue(agent as never);
2006
+ const { result } = renderHook(() => useEveAgentRuntime());
2007
+
2008
+ await act(async () => {
2009
+ result.current.thread.append({
2010
+ role: "user",
2011
+ content: [{ type: "text", text: "hi" }],
2012
+ });
2013
+ });
2014
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
2015
+
2016
+ let settled = false;
2017
+ await act(async () => {
2018
+ void result.current.threads.reloadMainThread().then(() => {
2019
+ settled = true;
2020
+ });
2021
+ });
2022
+ expect(resume).not.toHaveBeenCalled();
2023
+ expect(settled).toBe(false);
2024
+
2025
+ await act(async () => {
2026
+ resolveSend();
2027
+ });
2028
+ await waitFor(() => expect(settled).toBe(true));
2029
+ expect(resume).toHaveBeenCalledTimes(1);
2030
+ });
2031
+
2032
+ it("reads the session at dispatch time, after the first send created it", async () => {
2033
+ let resolveSend!: () => void;
2034
+ const send = vi.fn(
2035
+ () =>
2036
+ new Promise<void>((resolve) => {
2037
+ resolveSend = resolve;
2038
+ }),
2039
+ );
2040
+ const resume = vi.fn().mockResolvedValue(undefined);
2041
+ const agent = createAgent({ session: undefined, resume, send });
2042
+ mockUseEveAgent.mockReturnValue(agent as never);
2043
+ const { result, rerender } = renderHook(() => useEveAgentRuntime());
2044
+
2045
+ await act(async () => {
2046
+ result.current.thread.append({
2047
+ role: "user",
2048
+ content: [{ type: "text", text: "hi" }],
2049
+ });
2050
+ });
2051
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
2052
+
2053
+ let settled = false;
2054
+ await act(async () => {
2055
+ void result.current.threads.reloadMainThread().then(() => {
2056
+ settled = true;
2057
+ });
2058
+ });
2059
+
2060
+ mockUseEveAgent.mockReturnValue({ ...agent, session } as never);
2061
+ rerender();
2062
+ await act(async () => {
2063
+ resolveSend();
2064
+ });
2065
+ await waitFor(() => expect(settled).toBe(true));
2066
+ expect(resume).toHaveBeenCalledTimes(1);
2067
+ });
2068
+
2069
+ it("still replays a queued refetch when the run it waited on is cancelled", async () => {
2070
+ let resolveSend!: () => void;
2071
+ const send = vi.fn(
2072
+ () =>
2073
+ new Promise<void>((resolve) => {
2074
+ resolveSend = resolve;
2075
+ }),
2076
+ );
2077
+ const resume = vi.fn().mockResolvedValue(undefined);
2078
+ const cancel = vi.fn().mockResolvedValue({ status: "cancelled" });
2079
+ const agent = createAgent({ session, resume, send, cancel });
2080
+ mockUseEveAgent.mockReturnValue(agent as never);
2081
+ const { result } = renderHook(() => useEveAgentRuntime());
2082
+
2083
+ await act(async () => {
2084
+ result.current.thread.append({
2085
+ role: "user",
2086
+ content: [{ type: "text", text: "hi" }],
2087
+ });
2088
+ });
2089
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
2090
+
2091
+ let settled = false;
2092
+ await act(async () => {
2093
+ void result.current.threads.reloadMainThread().then(() => {
2094
+ settled = true;
2095
+ });
2096
+ result.current.thread.cancelRun();
2097
+ });
2098
+ await act(async () => {
2099
+ resolveSend();
2100
+ });
2101
+
2102
+ await waitFor(() => expect(settled).toBe(true));
2103
+ expect(resume).toHaveBeenCalledTimes(1);
2104
+ });
2105
+
2106
+ it("drops a queued refetch when the session is reset before it dispatches", async () => {
2107
+ let resolveSend!: () => void;
2108
+ const send = vi.fn(
2109
+ () =>
2110
+ new Promise<void>((resolve) => {
2111
+ resolveSend = resolve;
2112
+ }),
2113
+ );
2114
+ const resume = vi.fn();
2115
+ const agent = createAgent({ session, resume, send });
2116
+ mockUseEveAgent.mockReturnValue(agent as never);
2117
+ const { result } = renderHook(() => useEveAgentRuntime());
2118
+
2119
+ await act(async () => {
2120
+ result.current.thread.append({
2121
+ role: "user",
2122
+ content: [{ type: "text", text: "hi" }],
2123
+ });
2124
+ });
2125
+ await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
2126
+
2127
+ let settled = false;
2128
+ await act(async () => {
2129
+ void result.current.threads.reloadMainThread().then(() => {
2130
+ settled = true;
2131
+ });
2132
+ eveExtras.tryGet(result.current.thread.getState().extras)!.reset();
2133
+ });
2134
+ await act(async () => {
2135
+ resolveSend();
2136
+ });
2137
+
2138
+ await waitFor(() => expect(settled).toBe(true));
2139
+ expect(resume).not.toHaveBeenCalled();
2140
+ });
2141
+
2142
+ it("leaves the capability absent when the installed eve has no resume", async () => {
2143
+ const agent = createAgent({ session });
2144
+ mockUseEveAgent.mockReturnValue(agent as never);
2145
+ const { result } = renderHook(() => useEveAgentRuntime());
2146
+
2147
+ expect(result.current.thread.getState().capabilities.refetchThread).toBe(
2148
+ false,
2149
+ );
2150
+ await act(async () => {
2151
+ await result.current.threads.reloadMainThread();
2152
+ });
2153
+ });
2154
+ });