@assistant-ui/eve 0.0.14 → 0.0.15
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/convertEveMessages.d.ts +29 -3
- package/dist/convertEveMessages.d.ts.map +1 -1
- package/dist/convertEveMessages.js +81 -9
- package/dist/convertEveMessages.js.map +1 -1
- package/dist/useEveAgentRuntime.d.ts.map +1 -1
- package/dist/useEveAgentRuntime.js +26 -14
- package/dist/useEveAgentRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/convertEveMessages.test.ts +400 -3
- package/src/convertEveMessages.ts +144 -9
- package/src/useEveAgentRuntime.refetch.test.tsx +89 -0
- package/src/useEveAgentRuntime.test.tsx +439 -0
- package/src/useEveAgentRuntime.ts +66 -19
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook } from "@testing-library/react";
|
|
4
|
+
import { describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { HandleMessageStreamEvent } from "eve/client";
|
|
6
|
+
|
|
7
|
+
import { useEveAgentRuntime } from "./useEveAgentRuntime";
|
|
8
|
+
|
|
9
|
+
const completedTurn = (
|
|
10
|
+
turnId: string,
|
|
11
|
+
text: string,
|
|
12
|
+
reply: string,
|
|
13
|
+
sequence: number,
|
|
14
|
+
) =>
|
|
15
|
+
[
|
|
16
|
+
{
|
|
17
|
+
type: "turn.started",
|
|
18
|
+
data: { sequence, turnId },
|
|
19
|
+
meta: { at: "2026-01-02T10:00:00.000Z", id: `${turnId}-1` },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "message.received",
|
|
23
|
+
data: { message: text, sequence: sequence + 1, turnId },
|
|
24
|
+
meta: { at: "2026-01-02T10:00:01.000Z", id: `${turnId}-2` },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
type: "step.started",
|
|
28
|
+
data: { modelId: "m", sequence: sequence + 2, stepIndex: 0, turnId },
|
|
29
|
+
meta: { at: "2026-01-02T10:00:02.000Z", id: `${turnId}-3` },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
type: "message.completed",
|
|
33
|
+
data: {
|
|
34
|
+
finishReason: "stop",
|
|
35
|
+
message: reply,
|
|
36
|
+
sequence: sequence + 3,
|
|
37
|
+
stepIndex: 0,
|
|
38
|
+
turnId,
|
|
39
|
+
},
|
|
40
|
+
meta: { at: "2026-01-02T10:00:03.000Z", id: `${turnId}-4` },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: "turn.completed",
|
|
44
|
+
data: { sequence: sequence + 4, turnId },
|
|
45
|
+
meta: { at: "2026-01-02T10:00:04.000Z", id: `${turnId}-5` },
|
|
46
|
+
},
|
|
47
|
+
] as const satisfies readonly HandleMessageStreamEvent[];
|
|
48
|
+
|
|
49
|
+
const knownTurn = completedTurn("t1", "hi", "hello", 1);
|
|
50
|
+
const outOfBandTurn = completedTurn("t2", "answered elsewhere", "done", 6);
|
|
51
|
+
|
|
52
|
+
describe("useEveAgentRuntime against a durable eve session", () => {
|
|
53
|
+
it("surfaces a turn the server appended out of band after reloadMainThread", async () => {
|
|
54
|
+
const stream = vi.fn(async function* () {
|
|
55
|
+
yield* [...knownTurn, ...outOfBandTurn];
|
|
56
|
+
});
|
|
57
|
+
const session = {
|
|
58
|
+
state: { sessionId: "s1" },
|
|
59
|
+
stream,
|
|
60
|
+
send: () => {
|
|
61
|
+
throw new Error("the refetch test must not send");
|
|
62
|
+
},
|
|
63
|
+
} as never;
|
|
64
|
+
|
|
65
|
+
const { result } = renderHook(() =>
|
|
66
|
+
useEveAgentRuntime({ initialEvents: knownTurn as never, session }),
|
|
67
|
+
);
|
|
68
|
+
expect(
|
|
69
|
+
result.current.thread.getState().messages.map((m) => m.role),
|
|
70
|
+
).toEqual(["user", "assistant"]);
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
await result.current.threads.reloadMainThread();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(stream).toHaveBeenCalled();
|
|
77
|
+
const messages = result.current.thread.getState().messages;
|
|
78
|
+
expect(messages.map((m) => m.role)).toEqual([
|
|
79
|
+
"user",
|
|
80
|
+
"assistant",
|
|
81
|
+
"user",
|
|
82
|
+
"assistant",
|
|
83
|
+
]);
|
|
84
|
+
expect(messages[3]?.content).toMatchObject([
|
|
85
|
+
{ type: "text", text: "done" },
|
|
86
|
+
]);
|
|
87
|
+
expect(result.current.thread.getState().isRunning).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -328,6 +328,163 @@ describe("useEveAgentRuntime status forwarding", () => {
|
|
|
328
328
|
});
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
+
describe("useEveAgentRuntime tool approval responses", () => {
|
|
332
|
+
const textRequestData: EveMessageData = {
|
|
333
|
+
messages: [
|
|
334
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
335
|
+
{
|
|
336
|
+
id: "a1",
|
|
337
|
+
role: "assistant",
|
|
338
|
+
parts: [
|
|
339
|
+
{
|
|
340
|
+
type: "dynamic-tool",
|
|
341
|
+
state: "approval-requested",
|
|
342
|
+
toolCallId: "call_1",
|
|
343
|
+
toolName: "ask_question",
|
|
344
|
+
input: {},
|
|
345
|
+
approval: { id: "req_1" },
|
|
346
|
+
toolMetadata: {
|
|
347
|
+
eve: {
|
|
348
|
+
kind: "tool-call",
|
|
349
|
+
name: "ask_question",
|
|
350
|
+
inputRequest: {
|
|
351
|
+
requestId: "req_1",
|
|
352
|
+
prompt: "What should the subject line be?",
|
|
353
|
+
kind: "question",
|
|
354
|
+
display: "text",
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
],
|
|
360
|
+
},
|
|
361
|
+
],
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const flushMicrotasks = () =>
|
|
365
|
+
new Promise((resolve) => setTimeout(resolve, 0));
|
|
366
|
+
|
|
367
|
+
const processEvents = process as unknown as {
|
|
368
|
+
on(event: "unhandledRejection", listener: (reason: unknown) => void): void;
|
|
369
|
+
off(event: "unhandledRejection", listener: (reason: unknown) => void): void;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const respondToTextRequest = (
|
|
373
|
+
result: { current: ReturnType<typeof useEveAgentRuntime> },
|
|
374
|
+
response: { approved: boolean; reason?: string; optionId?: string },
|
|
375
|
+
) =>
|
|
376
|
+
result.current.thread
|
|
377
|
+
.getMessageById("a1")
|
|
378
|
+
.getMessagePartByToolCallId("call_1")
|
|
379
|
+
.respondToToolApproval(response);
|
|
380
|
+
|
|
381
|
+
it("keeps an unanswered free-form request pending through the default controls", async () => {
|
|
382
|
+
const rejections: unknown[] = [];
|
|
383
|
+
const onUnhandledRejection = (reason: unknown) => rejections.push(reason);
|
|
384
|
+
processEvents.on("unhandledRejection", onUnhandledRejection);
|
|
385
|
+
const agent = createAgent({ data: textRequestData });
|
|
386
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
387
|
+
|
|
388
|
+
try {
|
|
389
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
390
|
+
|
|
391
|
+
expect(() => respondToTextRequest(result, { approved: true })).toThrow(
|
|
392
|
+
/was not answered by this response/,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
await flushMicrotasks();
|
|
396
|
+
await flushMicrotasks();
|
|
397
|
+
|
|
398
|
+
expect(agent.send).not.toHaveBeenCalled();
|
|
399
|
+
expect(agent.respond).not.toHaveBeenCalled();
|
|
400
|
+
expect(rejections).toEqual([]);
|
|
401
|
+
} finally {
|
|
402
|
+
processEvents.off("unhandledRejection", onUnhandledRejection);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
const bareApprovalData = (toolMetadata?: {
|
|
407
|
+
eve: { kind: "tool-call"; name: string };
|
|
408
|
+
}): EveMessageData => ({
|
|
409
|
+
messages: [
|
|
410
|
+
{ id: "u1", role: "user", parts: [{ type: "text", text: "hi" }] },
|
|
411
|
+
{
|
|
412
|
+
id: "a1",
|
|
413
|
+
role: "assistant",
|
|
414
|
+
parts: [
|
|
415
|
+
{
|
|
416
|
+
type: "dynamic-tool",
|
|
417
|
+
state: "approval-requested",
|
|
418
|
+
toolCallId: "call_1",
|
|
419
|
+
toolName: "delete_file",
|
|
420
|
+
input: {},
|
|
421
|
+
approval: { id: "req_1" },
|
|
422
|
+
...(toolMetadata && { toolMetadata }),
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
],
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it.each([
|
|
430
|
+
["no eve tool metadata at all", undefined],
|
|
431
|
+
[
|
|
432
|
+
"eve metadata carrying no input request",
|
|
433
|
+
{ eve: { kind: "tool-call", name: "delete_file" } },
|
|
434
|
+
],
|
|
435
|
+
])(
|
|
436
|
+
"still answers an ordinary approval when the part has %s",
|
|
437
|
+
async (_label, toolMetadata) => {
|
|
438
|
+
const agent = createAgent({
|
|
439
|
+
data: bareApprovalData(toolMetadata as never),
|
|
440
|
+
});
|
|
441
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
442
|
+
|
|
443
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
444
|
+
expect(() =>
|
|
445
|
+
respondToTextRequest(result, { approved: true }),
|
|
446
|
+
).not.toThrow();
|
|
447
|
+
|
|
448
|
+
await flushMicrotasks();
|
|
449
|
+
|
|
450
|
+
expect(agent.respond).toHaveBeenCalledWith([
|
|
451
|
+
{ requestId: "req_1", optionId: "approve" },
|
|
452
|
+
]);
|
|
453
|
+
},
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
it("maps a refusal on a request the data does not carry", async () => {
|
|
457
|
+
const agent = createAgent({ data: bareApprovalData(undefined) });
|
|
458
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
459
|
+
|
|
460
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
461
|
+
respondToTextRequest(result, { approved: false, reason: "not safe" });
|
|
462
|
+
|
|
463
|
+
await flushMicrotasks();
|
|
464
|
+
|
|
465
|
+
expect(agent.respond).toHaveBeenCalledWith([
|
|
466
|
+
{ requestId: "req_1", optionId: "cancel", text: "not safe" },
|
|
467
|
+
]);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("submits a free-form answer as text without an option id", async () => {
|
|
471
|
+
const agent = createAgent({ data: textRequestData });
|
|
472
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
473
|
+
|
|
474
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
475
|
+
respondToTextRequest(result, {
|
|
476
|
+
approved: true,
|
|
477
|
+
reason: "Quarterly results",
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
await flushMicrotasks();
|
|
481
|
+
|
|
482
|
+
expect(agent.respond).toHaveBeenCalledWith([
|
|
483
|
+
{ requestId: "req_1", text: "Quarterly results" },
|
|
484
|
+
]);
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
331
488
|
const settledData: EveMessageData = {
|
|
332
489
|
messages: [
|
|
333
490
|
{ id: "u1", role: "user", parts: [{ type: "text", text: "earlier" }] },
|
|
@@ -1580,3 +1737,285 @@ describe("useEveAgentRuntime concurrent sends", () => {
|
|
|
1580
1737
|
]);
|
|
1581
1738
|
});
|
|
1582
1739
|
});
|
|
1740
|
+
|
|
1741
|
+
describe("useEveAgentRuntime cancel binding", () => {
|
|
1742
|
+
it("cancels through the eve 0.38+ cancel binding when the agent provides it", async () => {
|
|
1743
|
+
const cancel = vi.fn().mockResolvedValue({ status: "cancelled" });
|
|
1744
|
+
const agent = createAgent({ status: "streaming", cancel });
|
|
1745
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1746
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1747
|
+
|
|
1748
|
+
await act(async () => {
|
|
1749
|
+
result.current.thread.cancelRun();
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
1753
|
+
expect(agent.stop).not.toHaveBeenCalled();
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
it("falls back to the pre-0.38 stop binding when cancel is absent", async () => {
|
|
1757
|
+
const agent = createAgent({ status: "streaming" });
|
|
1758
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1759
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1760
|
+
|
|
1761
|
+
await act(async () => {
|
|
1762
|
+
result.current.thread.cancelRun();
|
|
1763
|
+
});
|
|
1764
|
+
|
|
1765
|
+
expect(agent.stop).toHaveBeenCalledTimes(1);
|
|
1766
|
+
});
|
|
1767
|
+
|
|
1768
|
+
it("drops a queued send when the durable cancel settles late", async () => {
|
|
1769
|
+
let resolveFirstSend!: () => void;
|
|
1770
|
+
const send = vi
|
|
1771
|
+
.fn()
|
|
1772
|
+
.mockImplementationOnce(
|
|
1773
|
+
() =>
|
|
1774
|
+
new Promise<void>((resolve) => {
|
|
1775
|
+
resolveFirstSend = resolve;
|
|
1776
|
+
}),
|
|
1777
|
+
)
|
|
1778
|
+
.mockResolvedValue(undefined);
|
|
1779
|
+
let resolveCancel!: () => void;
|
|
1780
|
+
const cancel = vi.fn(
|
|
1781
|
+
() =>
|
|
1782
|
+
new Promise<{ status: string }>((resolve) => {
|
|
1783
|
+
resolveCancel = () => resolve({ status: "cancelled" });
|
|
1784
|
+
}),
|
|
1785
|
+
);
|
|
1786
|
+
const agent = createAgent({ data: settledData, send, cancel });
|
|
1787
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1788
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1789
|
+
|
|
1790
|
+
await act(async () => {
|
|
1791
|
+
result.current.thread.append({
|
|
1792
|
+
role: "user",
|
|
1793
|
+
content: [{ type: "text", text: "first" }],
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
1797
|
+
|
|
1798
|
+
await act(async () => {
|
|
1799
|
+
result.current.thread.append({
|
|
1800
|
+
role: "user",
|
|
1801
|
+
content: [{ type: "text", text: "queued" }],
|
|
1802
|
+
});
|
|
1803
|
+
});
|
|
1804
|
+
act(() => {
|
|
1805
|
+
result.current.thread.cancelRun();
|
|
1806
|
+
});
|
|
1807
|
+
expect(cancel).toHaveBeenCalledTimes(1);
|
|
1808
|
+
|
|
1809
|
+
await act(async () => {
|
|
1810
|
+
resolveFirstSend();
|
|
1811
|
+
resolveCancel();
|
|
1812
|
+
});
|
|
1813
|
+
expect(send).toHaveBeenCalledTimes(1);
|
|
1814
|
+
});
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
describe("useEveAgentRuntime thread refetch", () => {
|
|
1818
|
+
const session = { sessionId: "s1" };
|
|
1819
|
+
|
|
1820
|
+
it("replays the session through eve resume when the thread is refetched", async () => {
|
|
1821
|
+
const resume = vi.fn().mockResolvedValue(undefined);
|
|
1822
|
+
const agent = createAgent({ session, resume });
|
|
1823
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1824
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1825
|
+
expect(result.current.thread.getState().capabilities.refetchThread).toBe(
|
|
1826
|
+
true,
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
await act(async () => {
|
|
1830
|
+
await result.current.threads.reloadMainThread();
|
|
1831
|
+
});
|
|
1832
|
+
|
|
1833
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
1834
|
+
});
|
|
1835
|
+
|
|
1836
|
+
it("propagates a failed replay to the refetch caller", async () => {
|
|
1837
|
+
const resume = vi.fn().mockRejectedValue(new Error("replay failed"));
|
|
1838
|
+
const agent = createAgent({ session, resume });
|
|
1839
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1840
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1841
|
+
|
|
1842
|
+
await act(async () => {
|
|
1843
|
+
await expect(result.current.threads.reloadMainThread()).rejects.toThrow(
|
|
1844
|
+
"replay failed",
|
|
1845
|
+
);
|
|
1846
|
+
});
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
it("resolves without replaying when no session exists yet", async () => {
|
|
1850
|
+
const resume = vi.fn();
|
|
1851
|
+
const agent = createAgent({ session: undefined, resume });
|
|
1852
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1853
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1854
|
+
|
|
1855
|
+
await act(async () => {
|
|
1856
|
+
await result.current.threads.reloadMainThread();
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
expect(resume).not.toHaveBeenCalled();
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it("waits for a turn dispatched before it to park, then replays", async () => {
|
|
1863
|
+
let resolveSend!: () => void;
|
|
1864
|
+
const send = vi.fn(
|
|
1865
|
+
() =>
|
|
1866
|
+
new Promise<void>((resolve) => {
|
|
1867
|
+
resolveSend = resolve;
|
|
1868
|
+
}),
|
|
1869
|
+
);
|
|
1870
|
+
const resume = vi.fn().mockResolvedValue(undefined);
|
|
1871
|
+
const agent = createAgent({ session, resume, send });
|
|
1872
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1873
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1874
|
+
|
|
1875
|
+
await act(async () => {
|
|
1876
|
+
result.current.thread.append({
|
|
1877
|
+
role: "user",
|
|
1878
|
+
content: [{ type: "text", text: "hi" }],
|
|
1879
|
+
});
|
|
1880
|
+
});
|
|
1881
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
1882
|
+
|
|
1883
|
+
let settled = false;
|
|
1884
|
+
await act(async () => {
|
|
1885
|
+
void result.current.threads.reloadMainThread().then(() => {
|
|
1886
|
+
settled = true;
|
|
1887
|
+
});
|
|
1888
|
+
});
|
|
1889
|
+
expect(resume).not.toHaveBeenCalled();
|
|
1890
|
+
expect(settled).toBe(false);
|
|
1891
|
+
|
|
1892
|
+
await act(async () => {
|
|
1893
|
+
resolveSend();
|
|
1894
|
+
});
|
|
1895
|
+
await waitFor(() => expect(settled).toBe(true));
|
|
1896
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it("reads the session at dispatch time, after the first send created it", async () => {
|
|
1900
|
+
let resolveSend!: () => void;
|
|
1901
|
+
const send = vi.fn(
|
|
1902
|
+
() =>
|
|
1903
|
+
new Promise<void>((resolve) => {
|
|
1904
|
+
resolveSend = resolve;
|
|
1905
|
+
}),
|
|
1906
|
+
);
|
|
1907
|
+
const resume = vi.fn().mockResolvedValue(undefined);
|
|
1908
|
+
const agent = createAgent({ session: undefined, resume, send });
|
|
1909
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1910
|
+
const { result, rerender } = renderHook(() => useEveAgentRuntime());
|
|
1911
|
+
|
|
1912
|
+
await act(async () => {
|
|
1913
|
+
result.current.thread.append({
|
|
1914
|
+
role: "user",
|
|
1915
|
+
content: [{ type: "text", text: "hi" }],
|
|
1916
|
+
});
|
|
1917
|
+
});
|
|
1918
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
1919
|
+
|
|
1920
|
+
let settled = false;
|
|
1921
|
+
await act(async () => {
|
|
1922
|
+
void result.current.threads.reloadMainThread().then(() => {
|
|
1923
|
+
settled = true;
|
|
1924
|
+
});
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
mockUseEveAgent.mockReturnValue({ ...agent, session } as never);
|
|
1928
|
+
rerender();
|
|
1929
|
+
await act(async () => {
|
|
1930
|
+
resolveSend();
|
|
1931
|
+
});
|
|
1932
|
+
await waitFor(() => expect(settled).toBe(true));
|
|
1933
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
it("still replays a queued refetch when the run it waited on is cancelled", async () => {
|
|
1937
|
+
let resolveSend!: () => void;
|
|
1938
|
+
const send = vi.fn(
|
|
1939
|
+
() =>
|
|
1940
|
+
new Promise<void>((resolve) => {
|
|
1941
|
+
resolveSend = resolve;
|
|
1942
|
+
}),
|
|
1943
|
+
);
|
|
1944
|
+
const resume = vi.fn().mockResolvedValue(undefined);
|
|
1945
|
+
const cancel = vi.fn().mockResolvedValue({ status: "cancelled" });
|
|
1946
|
+
const agent = createAgent({ session, resume, send, cancel });
|
|
1947
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1948
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1949
|
+
|
|
1950
|
+
await act(async () => {
|
|
1951
|
+
result.current.thread.append({
|
|
1952
|
+
role: "user",
|
|
1953
|
+
content: [{ type: "text", text: "hi" }],
|
|
1954
|
+
});
|
|
1955
|
+
});
|
|
1956
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
1957
|
+
|
|
1958
|
+
let settled = false;
|
|
1959
|
+
await act(async () => {
|
|
1960
|
+
void result.current.threads.reloadMainThread().then(() => {
|
|
1961
|
+
settled = true;
|
|
1962
|
+
});
|
|
1963
|
+
result.current.thread.cancelRun();
|
|
1964
|
+
});
|
|
1965
|
+
await act(async () => {
|
|
1966
|
+
resolveSend();
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
await waitFor(() => expect(settled).toBe(true));
|
|
1970
|
+
expect(resume).toHaveBeenCalledTimes(1);
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
it("drops a queued refetch when the session is reset before it dispatches", async () => {
|
|
1974
|
+
let resolveSend!: () => void;
|
|
1975
|
+
const send = vi.fn(
|
|
1976
|
+
() =>
|
|
1977
|
+
new Promise<void>((resolve) => {
|
|
1978
|
+
resolveSend = resolve;
|
|
1979
|
+
}),
|
|
1980
|
+
);
|
|
1981
|
+
const resume = vi.fn();
|
|
1982
|
+
const agent = createAgent({ session, resume, send });
|
|
1983
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
1984
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
1985
|
+
|
|
1986
|
+
await act(async () => {
|
|
1987
|
+
result.current.thread.append({
|
|
1988
|
+
role: "user",
|
|
1989
|
+
content: [{ type: "text", text: "hi" }],
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
await waitFor(() => expect(send).toHaveBeenCalledTimes(1));
|
|
1993
|
+
|
|
1994
|
+
let settled = false;
|
|
1995
|
+
await act(async () => {
|
|
1996
|
+
void result.current.threads.reloadMainThread().then(() => {
|
|
1997
|
+
settled = true;
|
|
1998
|
+
});
|
|
1999
|
+
eveExtras.tryGet(result.current.thread.getState().extras)!.reset();
|
|
2000
|
+
});
|
|
2001
|
+
await act(async () => {
|
|
2002
|
+
resolveSend();
|
|
2003
|
+
});
|
|
2004
|
+
|
|
2005
|
+
await waitFor(() => expect(settled).toBe(true));
|
|
2006
|
+
expect(resume).not.toHaveBeenCalled();
|
|
2007
|
+
});
|
|
2008
|
+
|
|
2009
|
+
it("leaves the capability absent when the installed eve has no resume", async () => {
|
|
2010
|
+
const agent = createAgent({ session });
|
|
2011
|
+
mockUseEveAgent.mockReturnValue(agent as never);
|
|
2012
|
+
const { result } = renderHook(() => useEveAgentRuntime());
|
|
2013
|
+
|
|
2014
|
+
expect(result.current.thread.getState().capabilities.refetchThread).toBe(
|
|
2015
|
+
false,
|
|
2016
|
+
);
|
|
2017
|
+
await act(async () => {
|
|
2018
|
+
await result.current.threads.reloadMainThread();
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "eve/react";
|
|
29
29
|
import {
|
|
30
30
|
convertEveMessages,
|
|
31
|
+
findEveInputRequest,
|
|
31
32
|
getEveMessageContent,
|
|
32
33
|
toEveInputResponse,
|
|
33
34
|
} from "./convertEveMessages";
|
|
@@ -199,10 +200,9 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
199
200
|
const hasExecutingTools = Object.values(toolStatuses).some(
|
|
200
201
|
(status) => status?.type === "executing",
|
|
201
202
|
);
|
|
202
|
-
const
|
|
203
|
-
agent.status === "submitted" ||
|
|
204
|
-
|
|
205
|
-
hasExecutingTools;
|
|
203
|
+
const providerIsRunning =
|
|
204
|
+
agent.status === "submitted" || agent.status === "streaming";
|
|
205
|
+
const isRunning = providerIsRunning || hasExecutingTools;
|
|
206
206
|
|
|
207
207
|
const convertedMessages = useMemo(() => {
|
|
208
208
|
const createdAtByMessageId = createdAtByMessageIdRef.current;
|
|
@@ -230,6 +230,8 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
230
230
|
const messages = stagedMessages ?? convertedMessages;
|
|
231
231
|
const messagesRef = useRef(messages);
|
|
232
232
|
messagesRef.current = messages;
|
|
233
|
+
const agentRef = useRef(agent);
|
|
234
|
+
agentRef.current = agent;
|
|
233
235
|
|
|
234
236
|
// Upstream `EveAgentStore` `send` and `respond` reject while a turn is in
|
|
235
237
|
// flight and only resolve once the turn's stream parks, so a pending chain
|
|
@@ -239,18 +241,22 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
239
241
|
const sendEpochRef = useRef(0);
|
|
240
242
|
// A cancel drops the queued send but keeps its draft for a later promotion;
|
|
241
243
|
// only a reset discards the draft with the session, so the two need separate
|
|
242
|
-
// counters.
|
|
244
|
+
// counters. A queued read has no draft and outlives a cancel, so it waits on
|
|
245
|
+
// the reset counter instead.
|
|
243
246
|
const resetEpochRef = useRef(0);
|
|
244
247
|
const isMountedRef = useRef(true);
|
|
245
248
|
const runtimeRef = useRef<ReturnType<typeof useExternalStoreRuntime> | null>(
|
|
246
249
|
null,
|
|
247
250
|
);
|
|
248
251
|
|
|
249
|
-
const enqueueSend = (
|
|
250
|
-
|
|
252
|
+
const enqueueSend = (
|
|
253
|
+
dispatch: () => Promise<void>,
|
|
254
|
+
epochRef: { current: number } = sendEpochRef,
|
|
255
|
+
) => {
|
|
256
|
+
const epoch = epochRef.current;
|
|
251
257
|
const next = sendChainRef.current.then(() => {
|
|
252
|
-
if (
|
|
253
|
-
|
|
258
|
+
if (!isMountedRef.current) throw sendAbandonedError;
|
|
259
|
+
if (epoch !== epochRef.current) throw sendCancelledError;
|
|
254
260
|
return dispatch();
|
|
255
261
|
});
|
|
256
262
|
sendChainRef.current = next.catch(() => {});
|
|
@@ -339,7 +345,7 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
339
345
|
const runtime = useExternalStoreRuntime({
|
|
340
346
|
...pickExternalStoreSharedOptions(options),
|
|
341
347
|
messages,
|
|
342
|
-
isRunning,
|
|
348
|
+
isRunning: providerIsRunning,
|
|
343
349
|
extras,
|
|
344
350
|
unstable_enableToolInvocations: true,
|
|
345
351
|
setToolStatuses,
|
|
@@ -419,18 +425,59 @@ export const useEveAgentRuntime = (options: UseEveAgentRuntimeOptions = {}) => {
|
|
|
419
425
|
},
|
|
420
426
|
}
|
|
421
427
|
: {}),
|
|
422
|
-
onCancel: () => {
|
|
428
|
+
onCancel: async () => {
|
|
423
429
|
sendEpochRef.current += 1;
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
430
|
+
// Eve 0.38 replaced the binding's local-abort `stop()` with the durable
|
|
431
|
+
// `cancel()`, so the adapter detects which side of that break the host's
|
|
432
|
+
// eve provides instead of pinning the peer range to one of them.
|
|
433
|
+
const controls = agent as
|
|
434
|
+
| { readonly cancel: () => Promise<unknown> }
|
|
435
|
+
| { readonly stop: () => void };
|
|
436
|
+
if ("cancel" in controls) {
|
|
437
|
+
await controls.cancel();
|
|
438
|
+
} else {
|
|
439
|
+
controls.stop();
|
|
432
440
|
}
|
|
433
441
|
},
|
|
442
|
+
// Hosts below eve 0.44.1 expose no `resume`; leaving the capability absent
|
|
443
|
+
// keeps `threads.reloadMainThread()` on core's no-capability no-op.
|
|
444
|
+
...("resume" in agent
|
|
445
|
+
? {
|
|
446
|
+
onRefetchThread: () =>
|
|
447
|
+
// `resume()` rejects during a turn, so the replay rides the send
|
|
448
|
+
// chain like every other dispatch and runs once the turn parks; a
|
|
449
|
+
// cancel of that turn does not drop it, or the caller would be told
|
|
450
|
+
// the thread was refetched when it was not. Upstream shares one
|
|
451
|
+
// replay across concurrent `resume()` calls, so a refetch
|
|
452
|
+
// dispatched while `resume: true` is replaying on mount joins that
|
|
453
|
+
// read instead of taking a fresh one. The session is read at
|
|
454
|
+
// dispatch time because nothing durable exists before the first
|
|
455
|
+
// send lands.
|
|
456
|
+
enqueueSend(async () => {
|
|
457
|
+
const live = agentRef.current;
|
|
458
|
+
if (live.session === undefined) return;
|
|
459
|
+
await live.resume();
|
|
460
|
+
}, resetEpochRef).catch((error) => {
|
|
461
|
+
if (isDroppedSend(error)) return;
|
|
462
|
+
throw error;
|
|
463
|
+
}),
|
|
464
|
+
}
|
|
465
|
+
: {}),
|
|
466
|
+
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.
|
|
471
|
+
const inputResponse = toEveInputResponse(
|
|
472
|
+
response,
|
|
473
|
+
findEveInputRequest(agent.data, response.approvalId),
|
|
474
|
+
);
|
|
475
|
+
return enqueueSend(() => agent.respond([inputResponse])).catch(
|
|
476
|
+
(error) => {
|
|
477
|
+
if (!isDroppedSend(error)) throw error;
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
},
|
|
434
481
|
});
|
|
435
482
|
runtimeRef.current = runtime;
|
|
436
483
|
|