@assistant-ui/react-ag-ui 0.1.6 → 0.1.9
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/runtime/AgUiThreadController.d.ts +10 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +86 -32
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +10 -0
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +9 -4
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/types.d.ts +6 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +6 -2
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +2 -2
- package/src/runtime/AgUiThreadController.connect.test.ts +372 -0
- package/src/runtime/AgUiThreadController.notifications.test.ts +126 -0
- package/src/runtime/AgUiThreadController.ts +158 -36
- package/src/runtime/adapter/conversions.subagent-messages.test.ts +117 -0
- package/src/runtime/adapter/conversions.ts +40 -5
- package/src/runtime/types.ts +6 -0
- package/src/useAgUiRuntime.connect.test.tsx +169 -0
- package/src/useAgUiRuntime.external-store.test.tsx +46 -1
- package/src/useAgUiRuntime.ts +8 -2
|
@@ -407,6 +407,123 @@ describe("fromAgUiSnapshotMessages subagent attribution", () => {
|
|
|
407
407
|
]);
|
|
408
408
|
});
|
|
409
409
|
|
|
410
|
+
it("retains each nested record's pending-call admission when adjacent records are joined", () => {
|
|
411
|
+
const converted = fromAgUiSnapshotMessages(
|
|
412
|
+
[
|
|
413
|
+
{ id: "a", role: "assistant", content: "", toolCalls: [call("spawn")] },
|
|
414
|
+
{ id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
|
|
415
|
+
{
|
|
416
|
+
id: "s1",
|
|
417
|
+
role: "assistant",
|
|
418
|
+
content: "",
|
|
419
|
+
toolCalls: [call("one"), call("excluded")],
|
|
420
|
+
subagentRunId: "run",
|
|
421
|
+
metadata: {
|
|
422
|
+
custom: { agui: { pendingToolCallIds: ["one", "future"] } },
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "s2",
|
|
427
|
+
role: "assistant",
|
|
428
|
+
content: "",
|
|
429
|
+
toolCalls: [call("two")],
|
|
430
|
+
subagentRunId: "run",
|
|
431
|
+
metadata: { custom: { agui: { pendingToolCallIds: ["two"] } } },
|
|
432
|
+
},
|
|
433
|
+
],
|
|
434
|
+
{ joinAdjacentAssistantMessages: true },
|
|
435
|
+
new Map([["run", "spawn"]]),
|
|
436
|
+
) as (Message & {
|
|
437
|
+
metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
|
|
438
|
+
})[];
|
|
439
|
+
const nested = findCall(converted, "spawn")?.messages?.[0] as Message & {
|
|
440
|
+
metadata?: { custom?: { agui?: { pendingToolCallIds?: string[] } } };
|
|
441
|
+
};
|
|
442
|
+
expect(findCall(converted, "spawn")?.messages).toHaveLength(1);
|
|
443
|
+
expect(nested.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
|
|
444
|
+
"one",
|
|
445
|
+
"future",
|
|
446
|
+
"two",
|
|
447
|
+
]);
|
|
448
|
+
expect(converted[0]?.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
|
|
449
|
+
"one",
|
|
450
|
+
"future",
|
|
451
|
+
"two",
|
|
452
|
+
]);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it.each([false, true])(
|
|
456
|
+
"does not merge pending lists while folding a nested run when joinAdjacentAssistantMessages is %s",
|
|
457
|
+
(joinAdjacentAssistantMessages) => {
|
|
458
|
+
// An empty record does not open a turn, so the adjacent-record join
|
|
459
|
+
// leaves both records for the subagent fold.
|
|
460
|
+
const converted = fromAgUiSnapshotMessages(
|
|
461
|
+
[
|
|
462
|
+
{
|
|
463
|
+
id: "a",
|
|
464
|
+
role: "assistant",
|
|
465
|
+
content: "",
|
|
466
|
+
toolCalls: [call("spawn")],
|
|
467
|
+
},
|
|
468
|
+
{ id: "t", role: "tool", toolCallId: "spawn", content: '"ok"' },
|
|
469
|
+
{
|
|
470
|
+
id: "s1",
|
|
471
|
+
role: "assistant",
|
|
472
|
+
content: "",
|
|
473
|
+
subagentRunId: "run",
|
|
474
|
+
metadata: {
|
|
475
|
+
custom: {
|
|
476
|
+
agui: {
|
|
477
|
+
pendingToolCallIds: ["stale-a"],
|
|
478
|
+
interrupts: [{ id: "int-a", reason: "external" }],
|
|
479
|
+
},
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
id: "s2",
|
|
485
|
+
role: "assistant",
|
|
486
|
+
content: "done",
|
|
487
|
+
subagentRunId: "run",
|
|
488
|
+
metadata: {
|
|
489
|
+
custom: { agui: { pendingToolCallIds: ["stale-b"] } },
|
|
490
|
+
},
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
{ joinAdjacentAssistantMessages },
|
|
494
|
+
new Map([["run", "spawn"]]),
|
|
495
|
+
) as (Message & {
|
|
496
|
+
metadata?: {
|
|
497
|
+
custom?: {
|
|
498
|
+
agui?: {
|
|
499
|
+
pendingToolCallIds?: string[];
|
|
500
|
+
interrupts?: { id: string }[];
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
})[];
|
|
505
|
+
const nested = findCall(converted, "spawn")?.messages?.[0] as Message & {
|
|
506
|
+
metadata?: {
|
|
507
|
+
custom?: {
|
|
508
|
+
agui?: {
|
|
509
|
+
pendingToolCallIds?: string[];
|
|
510
|
+
interrupts?: { id: string }[];
|
|
511
|
+
};
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
};
|
|
515
|
+
expect(texts(nested)).toEqual(["done"]);
|
|
516
|
+
expect(
|
|
517
|
+
nested.metadata?.custom?.agui?.interrupts?.map(
|
|
518
|
+
(interrupt) => interrupt.id,
|
|
519
|
+
),
|
|
520
|
+
).toEqual(["int-a"]);
|
|
521
|
+
expect(nested.metadata?.custom?.agui?.pendingToolCallIds).toEqual([
|
|
522
|
+
"stale-b",
|
|
523
|
+
]);
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
|
|
410
527
|
it("keeps a deeply nested pending call visible through resolved spawning calls", () => {
|
|
411
528
|
const messages = [
|
|
412
529
|
{ id: "a1", role: "assistant", content: "", toolCalls: [call("top")] },
|
|
@@ -1078,6 +1078,7 @@ function readNamespacedMetadata(metadata: unknown): Record<string, unknown> {
|
|
|
1078
1078
|
function foldTurnRecords(
|
|
1079
1079
|
previous: CoreThreadMessageLike,
|
|
1080
1080
|
message: CoreThreadMessageLike,
|
|
1081
|
+
mergePendingToolCallIds: boolean,
|
|
1081
1082
|
): CoreThreadMessageLike {
|
|
1082
1083
|
const interrupts = [
|
|
1083
1084
|
...(readPersistedInterrupts(previous.metadata) ?? []),
|
|
@@ -1095,10 +1096,21 @@ function foldTurnRecords(
|
|
|
1095
1096
|
trailsCalls ? { ...entry, after: true } : entry,
|
|
1096
1097
|
),
|
|
1097
1098
|
];
|
|
1099
|
+
const previousPending = readPersistedPendingToolCallIds(previous.metadata);
|
|
1100
|
+
const messagePending = readPersistedPendingToolCallIds(message.metadata);
|
|
1101
|
+
const pendingToolCallIds =
|
|
1102
|
+
!mergePendingToolCallIds ||
|
|
1103
|
+
(previousPending === undefined && messagePending === undefined)
|
|
1104
|
+
? []
|
|
1105
|
+
: [
|
|
1106
|
+
...admittedUnresolvedToolCallIds(previous, false),
|
|
1107
|
+
...admittedUnresolvedToolCallIds(message, false),
|
|
1108
|
+
];
|
|
1098
1109
|
const namespaced = {
|
|
1099
1110
|
...readNamespacedMetadata(previous.metadata),
|
|
1100
1111
|
...readNamespacedMetadata(message.metadata),
|
|
1101
1112
|
...(interrupts.length > 0 ? { interrupts } : {}),
|
|
1113
|
+
...(pendingToolCallIds.length > 0 ? { pendingToolCallIds } : {}),
|
|
1102
1114
|
...(opaqueReasoning.length > 0 ? { opaqueReasoning } : {}),
|
|
1103
1115
|
} satisfies AgUiCustomMetadata & Record<string, unknown>;
|
|
1104
1116
|
const custom = {
|
|
@@ -1134,6 +1146,16 @@ export type FromAgUiMessagesOptions = {
|
|
|
1134
1146
|
* Matches the `showThinking` option of `useAgUiRuntime`. Defaults to `true`.
|
|
1135
1147
|
*/
|
|
1136
1148
|
showThinking?: boolean;
|
|
1149
|
+
/**
|
|
1150
|
+
* Whether to join every run of adjacent assistant records into one message,
|
|
1151
|
+
* for a backend whose history splits one turn across records with ids of
|
|
1152
|
+
* their own. The merged message keeps the id of the record carrying the
|
|
1153
|
+
* answer, with the parts in wire order. Records that belong to separate runs
|
|
1154
|
+
* are joined too when no user message separates them. Matches the
|
|
1155
|
+
* `joinAdjacentAssistantMessages` option of `useAgUiRuntime`. Defaults to
|
|
1156
|
+
* `false`.
|
|
1157
|
+
*/
|
|
1158
|
+
joinAdjacentAssistantMessages?: boolean;
|
|
1137
1159
|
};
|
|
1138
1160
|
|
|
1139
1161
|
const readSubagentRunId = (record: Record<string, unknown>) => {
|
|
@@ -1259,7 +1281,9 @@ export function fromAgUiSnapshotMessages(
|
|
|
1259
1281
|
)
|
|
1260
1282
|
.reduce<CoreThreadMessageLike | undefined>(
|
|
1261
1283
|
(merged, message) =>
|
|
1262
|
-
|
|
1284
|
+
// Pending-call admission is taken from the original records below.
|
|
1285
|
+
// Merging lists in this fold would union ids that admission skips.
|
|
1286
|
+
merged ? foldTurnRecords(merged, message, false) : message,
|
|
1263
1287
|
undefined,
|
|
1264
1288
|
);
|
|
1265
1289
|
const content = Array.isArray(folded?.content) ? folded.content : [];
|
|
@@ -1418,6 +1442,8 @@ function convertAgUiMessages(
|
|
|
1418
1442
|
options?: FromAgUiMessagesOptions,
|
|
1419
1443
|
): CoreThreadMessageLike[] {
|
|
1420
1444
|
const showThinking = options?.showThinking ?? true;
|
|
1445
|
+
const joinAdjacentAssistantMessages =
|
|
1446
|
+
options?.joinAdjacentAssistantMessages ?? false;
|
|
1421
1447
|
const converted: CoreThreadMessageLike[] = [];
|
|
1422
1448
|
const a2uiBuckets = new Map<string, A2uiState>();
|
|
1423
1449
|
const a2uiBucketOwnerIndices = new Map<string, number>();
|
|
@@ -1779,7 +1805,9 @@ function convertAgUiMessages(
|
|
|
1779
1805
|
// a container beside the records around it, which the live run renders as
|
|
1780
1806
|
// one message: the container joins the assistant record ahead of it, and
|
|
1781
1807
|
// what follows a container joins it until the turn carries text, where a
|
|
1782
|
-
// further text record opens a message of its own as it does live.
|
|
1808
|
+
// further text record opens a message of its own as it does live. An app
|
|
1809
|
+
// that knows its backend splits a turn across records it named itself opts
|
|
1810
|
+
// into joining every assistant record that follows one opening a turn.
|
|
1783
1811
|
const folded: CoreThreadMessageLike[] = [];
|
|
1784
1812
|
for (const message of converted) {
|
|
1785
1813
|
const previous = folded[folded.length - 1];
|
|
@@ -1787,10 +1815,17 @@ function convertAgUiMessages(
|
|
|
1787
1815
|
previous !== undefined &&
|
|
1788
1816
|
(isSyntheticToolCallContainer(message)
|
|
1789
1817
|
? opensTurn(previous)
|
|
1790
|
-
: isSyntheticToolCallContainer(previous) &&
|
|
1791
|
-
|
|
1818
|
+
: (isSyntheticToolCallContainer(previous) &&
|
|
1819
|
+
answersToolCallContainer(message)) ||
|
|
1820
|
+
(joinAdjacentAssistantMessages &&
|
|
1821
|
+
message.role === "assistant" &&
|
|
1822
|
+
opensTurn(previous)))
|
|
1792
1823
|
) {
|
|
1793
|
-
folded[folded.length - 1] = foldTurnRecords(
|
|
1824
|
+
folded[folded.length - 1] = foldTurnRecords(
|
|
1825
|
+
previous,
|
|
1826
|
+
message,
|
|
1827
|
+
joinAdjacentAssistantMessages,
|
|
1828
|
+
);
|
|
1794
1829
|
continue;
|
|
1795
1830
|
}
|
|
1796
1831
|
folded.push(message);
|
package/src/runtime/types.ts
CHANGED
|
@@ -94,6 +94,12 @@ export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
|
|
|
94
94
|
logger?: Partial<Logger>;
|
|
95
95
|
showThinking?: boolean;
|
|
96
96
|
joinStrategy?: JoinStrategy;
|
|
97
|
+
/**
|
|
98
|
+
* Join adjacent assistant records of an imported snapshot into one message.
|
|
99
|
+
* See `FromAgUiMessagesOptions.joinAdjacentAssistantMessages`. Defaults to
|
|
100
|
+
* `false`.
|
|
101
|
+
*/
|
|
102
|
+
joinAdjacentAssistantMessages?: boolean;
|
|
97
103
|
/**
|
|
98
104
|
* Return context entries for each run. `messages` is the exact AG-UI
|
|
99
105
|
* transcript sent in the run input, after transcript selection and message
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
6
|
+
import {
|
|
7
|
+
HttpAgent,
|
|
8
|
+
runHttpRequest,
|
|
9
|
+
transformHttpEventStream,
|
|
10
|
+
type RunAgentInput,
|
|
11
|
+
} from "@ag-ui/client";
|
|
12
|
+
import type { ThreadMessage } from "@assistant-ui/core";
|
|
13
|
+
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
14
|
+
|
|
15
|
+
const encoder = new TextEncoder();
|
|
16
|
+
const frame = (event: object) =>
|
|
17
|
+
encoder.encode(`data: ${JSON.stringify(event)}\n\n`);
|
|
18
|
+
|
|
19
|
+
// Each thread's run stays open until the request is aborted.
|
|
20
|
+
const openRun = (threadId: string, init?: RequestInit) =>
|
|
21
|
+
new Response(
|
|
22
|
+
new ReadableStream<Uint8Array>({
|
|
23
|
+
start(controller) {
|
|
24
|
+
controller.enqueue(
|
|
25
|
+
frame({ type: "RUN_STARTED", threadId, runId: `run-${threadId}` }),
|
|
26
|
+
);
|
|
27
|
+
controller.enqueue(
|
|
28
|
+
frame({
|
|
29
|
+
type: "TEXT_MESSAGE_START",
|
|
30
|
+
messageId: `answer-${threadId}`,
|
|
31
|
+
role: "assistant",
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
controller.enqueue(
|
|
35
|
+
frame({
|
|
36
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
37
|
+
messageId: `answer-${threadId}`,
|
|
38
|
+
delta: `streaming ${threadId}`,
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
init?.signal?.addEventListener("abort", () => controller.close(), {
|
|
42
|
+
once: true,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
{ headers: { "Content-Type": "text/event-stream" } },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
class ReconnectingAgent extends HttpAgent {
|
|
50
|
+
readonly connected: string[] = [];
|
|
51
|
+
|
|
52
|
+
override connectAgent(...args: Parameters<HttpAgent["connectAgent"]>) {
|
|
53
|
+
this.abortController = new AbortController();
|
|
54
|
+
return super.connectAgent(...args);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected override connect(input: RunAgentInput) {
|
|
58
|
+
this.connected.push(input.threadId);
|
|
59
|
+
return transformHttpEventStream(
|
|
60
|
+
runHttpRequest(() =>
|
|
61
|
+
this.fetch(`${this.url}/${input.threadId}/stream`, {
|
|
62
|
+
signal: this.abortController.signal,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const question = (id: string): ThreadMessage => ({
|
|
70
|
+
id: `question-${id}`,
|
|
71
|
+
role: "user",
|
|
72
|
+
content: [{ type: "text", text: `asked in ${id}` }],
|
|
73
|
+
attachments: [],
|
|
74
|
+
createdAt: new Date(0),
|
|
75
|
+
metadata: { custom: {} },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
function renderDurable() {
|
|
83
|
+
const agent = new ReconnectingAgent({
|
|
84
|
+
url: "https://agent.test",
|
|
85
|
+
threadId: "initial",
|
|
86
|
+
fetch: async (url: string, init?: RequestInit) =>
|
|
87
|
+
openRun(new URL(url).pathname.split("/")[1]!, init),
|
|
88
|
+
});
|
|
89
|
+
const abortRun = vi.spyOn(agent, "abortRun");
|
|
90
|
+
const detachActiveRun = vi.spyOn(agent, "detachActiveRun");
|
|
91
|
+
const view = renderHook(() => {
|
|
92
|
+
const [threadId, setThreadId] = useState("initial");
|
|
93
|
+
return useAgUiRuntime({
|
|
94
|
+
agent,
|
|
95
|
+
adapters: {
|
|
96
|
+
threadList: {
|
|
97
|
+
threadId,
|
|
98
|
+
onSwitchToThread: async (id) => {
|
|
99
|
+
agent.threadId = id;
|
|
100
|
+
setThreadId(id);
|
|
101
|
+
return { messages: [question(id)], unstable_resume: true };
|
|
102
|
+
},
|
|
103
|
+
onSwitchToNewThread: () => {
|
|
104
|
+
agent.threadId = "thread-new";
|
|
105
|
+
setThreadId("thread-new");
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
return { agent, abortRun, detachActiveRun, ...view };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const lastText = (messages: readonly ThreadMessage[]) => {
|
|
115
|
+
const part = messages.at(-1)?.content[0];
|
|
116
|
+
return part?.type === "text" ? part.text : undefined;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
describe("useAgUiRuntime with an agent that implements connect()", () => {
|
|
120
|
+
it("leaves a running thread by detaching, then rejoins the next thread's run", async () => {
|
|
121
|
+
const { agent, abortRun, detachActiveRun, result } = renderDurable();
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
await result.current.threads.switchToThread("thread-a");
|
|
125
|
+
});
|
|
126
|
+
await waitFor(() =>
|
|
127
|
+
expect(lastText(result.current.thread.getState().messages)).toBe(
|
|
128
|
+
"streaming thread-a",
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
await result.current.threads.switchToThread("thread-b");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await waitFor(() =>
|
|
137
|
+
expect(lastText(result.current.thread.getState().messages)).toBe(
|
|
138
|
+
"streaming thread-b",
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
expect(agent.connected).toEqual(["thread-a", "thread-b"]);
|
|
142
|
+
expect(
|
|
143
|
+
result.current.thread.getState().messages.map((message) => message.id),
|
|
144
|
+
).toEqual(["question-thread-b", "answer-thread-b"]);
|
|
145
|
+
expect(detachActiveRun).toHaveBeenCalled();
|
|
146
|
+
expect(abortRun).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
await act(async () => {
|
|
149
|
+
await result.current.threads.switchToNewThread();
|
|
150
|
+
});
|
|
151
|
+
expect(abortRun).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("aborts the run when the user cancels", async () => {
|
|
155
|
+
const { abortRun, result } = renderDurable();
|
|
156
|
+
await act(async () => {
|
|
157
|
+
await result.current.threads.switchToThread("thread-a");
|
|
158
|
+
});
|
|
159
|
+
await waitFor(() =>
|
|
160
|
+
expect(result.current.thread.getState().isRunning).toBe(true),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.thread.cancelRun();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(abortRun).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { act, cleanup, renderHook } from "@testing-library/react";
|
|
4
|
-
import
|
|
4
|
+
import { HttpAgent } from "@ag-ui/client";
|
|
5
5
|
import { afterEach, expect, it, vi } from "vitest";
|
|
6
6
|
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
7
7
|
|
|
@@ -88,3 +88,48 @@ it("passes joinStrategy to the active run", async () => {
|
|
|
88
88
|
{ type: "text", text: "B" },
|
|
89
89
|
]);
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
it("batches text deltas emitted from one response before re-rendering the host", async () => {
|
|
93
|
+
const deltas = Array.from({ length: 20 }, (_, index) => String(index));
|
|
94
|
+
const events = [
|
|
95
|
+
{ type: "RUN_STARTED", threadId: "main", runId: "run" },
|
|
96
|
+
{ type: "TEXT_MESSAGE_START", messageId: "answer" },
|
|
97
|
+
...deltas.map((delta) => ({
|
|
98
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
99
|
+
messageId: "answer",
|
|
100
|
+
delta,
|
|
101
|
+
})),
|
|
102
|
+
{ type: "TEXT_MESSAGE_END", messageId: "answer" },
|
|
103
|
+
{ type: "RUN_FINISHED", threadId: "main", runId: "run" },
|
|
104
|
+
];
|
|
105
|
+
const agent = new HttpAgent({
|
|
106
|
+
url: "http://agent.test/agent",
|
|
107
|
+
fetch: async () =>
|
|
108
|
+
new Response(
|
|
109
|
+
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
|
|
110
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
111
|
+
),
|
|
112
|
+
});
|
|
113
|
+
let renders = 0;
|
|
114
|
+
const { result } = renderHook(() => {
|
|
115
|
+
renders++;
|
|
116
|
+
return useAgUiRuntime({ agent });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const baseline = renders;
|
|
120
|
+
const pending = result.current.thread.append({
|
|
121
|
+
role: "user",
|
|
122
|
+
content: [{ type: "text", text: "go" }],
|
|
123
|
+
});
|
|
124
|
+
await pending;
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
126
|
+
|
|
127
|
+
const assistant = result.current.thread
|
|
128
|
+
.getState()
|
|
129
|
+
.messages.find((message) => message.role === "assistant");
|
|
130
|
+
expect(assistant).toMatchObject({
|
|
131
|
+
content: [{ type: "text", text: deltas.join("") }],
|
|
132
|
+
status: { type: "complete" },
|
|
133
|
+
});
|
|
134
|
+
expect(renders - baseline).toBeLessThanOrEqual(6);
|
|
135
|
+
});
|
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -76,6 +76,8 @@ export function useAgUiRuntime(
|
|
|
76
76
|
logger,
|
|
77
77
|
showThinking: options.showThinking ?? true,
|
|
78
78
|
joinStrategy: options.joinStrategy,
|
|
79
|
+
joinAdjacentAssistantMessages:
|
|
80
|
+
options.joinAdjacentAssistantMessages ?? false,
|
|
79
81
|
getRunContext: options.getRunContext,
|
|
80
82
|
resumeTranscript: options.resumeTranscript,
|
|
81
83
|
autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
|
|
@@ -98,6 +100,8 @@ export function useAgUiRuntime(
|
|
|
98
100
|
logger,
|
|
99
101
|
showThinking: options.showThinking ?? true,
|
|
100
102
|
joinStrategy: options.joinStrategy,
|
|
103
|
+
joinAdjacentAssistantMessages:
|
|
104
|
+
options.joinAdjacentAssistantMessages ?? false,
|
|
101
105
|
getRunContext: options.getRunContext,
|
|
102
106
|
resumeTranscript: options.resumeTranscript,
|
|
103
107
|
autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
|
|
@@ -223,12 +227,14 @@ export function useAgUiRuntime(
|
|
|
223
227
|
const abandonPreviousThread = () => {
|
|
224
228
|
queueRef.current?.notifyCancelled();
|
|
225
229
|
if (core.isRunning()) {
|
|
226
|
-
|
|
230
|
+
try {
|
|
231
|
+
core.leave();
|
|
232
|
+
} catch (error) {
|
|
227
233
|
logger.error?.(
|
|
228
234
|
"[agui] cancelling the run on thread switch failed",
|
|
229
235
|
error,
|
|
230
236
|
);
|
|
231
|
-
}
|
|
237
|
+
}
|
|
232
238
|
}
|
|
233
239
|
queueRef.current?.clear();
|
|
234
240
|
};
|