@assistant-ui/react-ag-ui 0.1.1 → 0.1.3
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/README.md +2 -0
- package/dist/runtime/AgUiThreadController.d.ts +11 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +242 -25
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts +9 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +184 -9
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +2 -0
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +42 -12
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/adapter/subscriber.d.ts +0 -1
- package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
- package/dist/runtime/adapter/subscriber.js +1 -8
- package/dist/runtime/adapter/subscriber.js.map +1 -1
- package/dist/runtime/event-parser.d.ts.map +1 -1
- package/dist/runtime/event-parser.js +1 -0
- package/dist/runtime/event-parser.js.map +1 -1
- package/dist/runtime/types.d.ts +2 -0
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/runtime/types.js +2 -1
- package/dist/runtime/types.js.map +1 -1
- package/package.json +6 -5
- package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
- package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
- package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +346 -0
- package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
- package/src/runtime/AgUiThreadController.test.ts +173 -1
- package/src/runtime/AgUiThreadController.ts +528 -33
- package/src/runtime/adapter/conversions.content.test.ts +129 -0
- package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
- package/src/runtime/adapter/conversions.ts +331 -20
- package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
- package/src/runtime/adapter/run-aggregator.ts +63 -9
- package/src/runtime/adapter/subscriber.test.ts +1 -1
- package/src/runtime/adapter/subscriber.ts +1 -6
- package/src/runtime/event-parser.ts +3 -0
- package/src/runtime/types.ts +5 -0
- package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
- package/src/useAgUiRuntime.state.test.tsx +87 -0
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import jsonpatch, { type Operation } from "fast-json-patch";
|
|
23
23
|
import { readMcpAppResourceUri, toolContentText } from "../mcp-tool-result";
|
|
24
24
|
import { projectAgUiToolApprovals } from "./tool-approval";
|
|
25
|
+
import { withAgUiPartMessageMetadata } from "./conversions";
|
|
25
26
|
import {
|
|
26
27
|
AG_UI_METADATA_NAMESPACE,
|
|
27
28
|
A2UI_SURFACE_ACTIVITY_TYPE,
|
|
@@ -30,15 +31,12 @@ import {
|
|
|
30
31
|
type AgUiEvent,
|
|
31
32
|
type AgUiInterrupt,
|
|
32
33
|
type AgUiOpaqueReasoning,
|
|
34
|
+
MAX_SUBAGENT_DEPTH,
|
|
33
35
|
} from "../types";
|
|
34
36
|
import type { Logger } from "../logger";
|
|
35
37
|
|
|
36
38
|
const ROOT_SCOPE = "";
|
|
37
39
|
|
|
38
|
-
// Defensive guard against a malformed or cyclic parentToolCallId /
|
|
39
|
-
// parentSubagentRunId chain, not a realistic nesting depth.
|
|
40
|
-
const MAX_SUBAGENT_DEPTH = 16;
|
|
41
|
-
|
|
42
40
|
type SubagentRunState = {
|
|
43
41
|
subagentRunId: string;
|
|
44
42
|
name: string;
|
|
@@ -93,6 +91,7 @@ type ToolCallState = {
|
|
|
93
91
|
mcpAppServerId?: string;
|
|
94
92
|
artifact?: unknown;
|
|
95
93
|
modelContent?: ToolModelContentPart[];
|
|
94
|
+
resultMetadata?: Record<string, unknown>;
|
|
96
95
|
snapshotResultApplied: boolean;
|
|
97
96
|
subagentRunId?: string;
|
|
98
97
|
};
|
|
@@ -209,6 +208,7 @@ export class RunAggregator {
|
|
|
209
208
|
private interrupts: AgUiInterrupt[] | undefined;
|
|
210
209
|
private pendingToolCallIds: string[] | undefined;
|
|
211
210
|
private readonly textParts = new Map<string, { buffer: string }>();
|
|
211
|
+
private readonly pendingSnapshotText = new Map<string, string>();
|
|
212
212
|
private readonly activeTextMessageIdByScope = new Map<string, string>();
|
|
213
213
|
private readonly reasoningParts = new Map<
|
|
214
214
|
string,
|
|
@@ -267,6 +267,17 @@ export class RunAggregator {
|
|
|
267
267
|
return this.toolCalls.has(toolCallId);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
rebaseMessage(messageId: string): void {
|
|
271
|
+
const snapshotText = this.pendingSnapshotText.get(messageId);
|
|
272
|
+
this.resetMessageParts();
|
|
273
|
+
this.lastTextMessageId = messageId;
|
|
274
|
+
this.reportedServerMessageId = messageId;
|
|
275
|
+
if (snapshotText !== undefined) {
|
|
276
|
+
this.pendingSnapshotText.set(messageId, snapshotText);
|
|
277
|
+
}
|
|
278
|
+
this.startTextMessage(ROOT_SCOPE, messageId);
|
|
279
|
+
}
|
|
280
|
+
|
|
270
281
|
handle(event: AgUiEvent): void {
|
|
271
282
|
switch (event.type) {
|
|
272
283
|
case "RUN_STARTED": {
|
|
@@ -282,6 +293,20 @@ export class RunAggregator {
|
|
|
282
293
|
this.emit();
|
|
283
294
|
break;
|
|
284
295
|
}
|
|
296
|
+
case "MESSAGES_SNAPSHOT": {
|
|
297
|
+
this.pendingSnapshotText.clear();
|
|
298
|
+
for (const message of event.messages) {
|
|
299
|
+
if (
|
|
300
|
+
message.role !== "assistant" ||
|
|
301
|
+
typeof message.content !== "string"
|
|
302
|
+
)
|
|
303
|
+
continue;
|
|
304
|
+
const part = this.textParts.get(message.id);
|
|
305
|
+
if (part) part.buffer = message.content;
|
|
306
|
+
else this.pendingSnapshotText.set(message.id, message.content);
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
285
310
|
case "RUN_FINISHED": {
|
|
286
311
|
if (event.outcome?.type === "cancelled") {
|
|
287
312
|
this.interrupts = undefined;
|
|
@@ -368,7 +393,7 @@ export class RunAggregator {
|
|
|
368
393
|
const incomingId = event.messageId;
|
|
369
394
|
const scope = this.scopeOf(event);
|
|
370
395
|
if (scope === ROOT_SCOPE) {
|
|
371
|
-
this.beginDistinctTextMessage(incomingId);
|
|
396
|
+
this.beginDistinctTextMessage(incomingId, true);
|
|
372
397
|
this.reportServerMessageId(incomingId);
|
|
373
398
|
if (incomingId) this.lastTextMessageId = incomingId;
|
|
374
399
|
}
|
|
@@ -494,6 +519,7 @@ export class RunAggregator {
|
|
|
494
519
|
: undefined,
|
|
495
520
|
event.messageId,
|
|
496
521
|
event.mcpResult,
|
|
522
|
+
event.metadata,
|
|
497
523
|
);
|
|
498
524
|
this.emit();
|
|
499
525
|
break;
|
|
@@ -793,6 +819,7 @@ export class RunAggregator {
|
|
|
793
819
|
|
|
794
820
|
private resetMessageParts(): void {
|
|
795
821
|
this.clearTextParts();
|
|
822
|
+
this.pendingSnapshotText.clear();
|
|
796
823
|
this.reasoningParts.clear();
|
|
797
824
|
this.reasoningSignatures.clear();
|
|
798
825
|
this.reasoningSignatureIds.clear();
|
|
@@ -837,7 +864,10 @@ export class RunAggregator {
|
|
|
837
864
|
return scope === ROOT_SCOPE ? id : `${scope}\u0000${id}`;
|
|
838
865
|
}
|
|
839
866
|
|
|
840
|
-
private beginDistinctTextMessage(
|
|
867
|
+
private beginDistinctTextMessage(
|
|
868
|
+
messageId: string | undefined,
|
|
869
|
+
preserveSnapshotText = false,
|
|
870
|
+
): void {
|
|
841
871
|
if (
|
|
842
872
|
this.joinStrategy === "concat-content" ||
|
|
843
873
|
!messageId ||
|
|
@@ -854,7 +884,13 @@ export class RunAggregator {
|
|
|
854
884
|
const openToolCallIds = Array.from(this.toolCalls.values())
|
|
855
885
|
.filter((call) => call.result === undefined)
|
|
856
886
|
.map((call) => call.toolCallId);
|
|
887
|
+
const snapshotText = preserveSnapshotText
|
|
888
|
+
? this.pendingSnapshotText.get(messageId)
|
|
889
|
+
: undefined;
|
|
857
890
|
this.resetMessageParts();
|
|
891
|
+
if (snapshotText !== undefined) {
|
|
892
|
+
this.pendingSnapshotText.set(messageId, snapshotText);
|
|
893
|
+
}
|
|
858
894
|
this.onTextMessageStart(messageId, openToolCallIds);
|
|
859
895
|
this.restoreReasoning(carried);
|
|
860
896
|
}
|
|
@@ -946,15 +982,21 @@ export class RunAggregator {
|
|
|
946
982
|
private startTextMessage(scope: string, messageId?: string): string {
|
|
947
983
|
const id = messageId ?? this.generateTextKey();
|
|
948
984
|
const key = this.ensureTextPart(scope, id);
|
|
985
|
+
if (scope === ROOT_SCOPE && messageId) {
|
|
986
|
+
const snapshotText = this.pendingSnapshotText.get(messageId);
|
|
987
|
+
const part = this.textParts.get(key);
|
|
988
|
+
if (snapshotText !== undefined && part) {
|
|
989
|
+
part.buffer = snapshotText;
|
|
990
|
+
this.pendingSnapshotText.delete(messageId);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
949
993
|
this.activeTextMessageIdByScope.set(scope, key);
|
|
950
994
|
return key;
|
|
951
995
|
}
|
|
952
996
|
|
|
953
997
|
private resolveTextMessageId(scope: string, messageId?: string): string {
|
|
954
998
|
if (messageId) {
|
|
955
|
-
|
|
956
|
-
this.activeTextMessageIdByScope.set(scope, key);
|
|
957
|
-
return key;
|
|
999
|
+
return this.startTextMessage(scope, messageId);
|
|
958
1000
|
}
|
|
959
1001
|
|
|
960
1002
|
const active = this.activeTextMessageIdByScope.get(scope);
|
|
@@ -1098,6 +1140,7 @@ export class RunAggregator {
|
|
|
1098
1140
|
isError?: boolean,
|
|
1099
1141
|
toolMessageId?: string,
|
|
1100
1142
|
mcpResult?: Record<string, unknown>,
|
|
1143
|
+
metadata?: Record<string, unknown>,
|
|
1101
1144
|
) {
|
|
1102
1145
|
if (!id) return;
|
|
1103
1146
|
const modelText = toolContentText(content);
|
|
@@ -1149,6 +1192,9 @@ export class RunAggregator {
|
|
|
1149
1192
|
if (toolMessageId) {
|
|
1150
1193
|
entry.toolMessageId = toolMessageId;
|
|
1151
1194
|
}
|
|
1195
|
+
if (metadata !== undefined) {
|
|
1196
|
+
entry.resultMetadata = { ...entry.resultMetadata, ...metadata };
|
|
1197
|
+
}
|
|
1152
1198
|
this.lastResolvedToolCallIdByScope.set(scope, id);
|
|
1153
1199
|
}
|
|
1154
1200
|
|
|
@@ -1299,6 +1345,14 @@ export class RunAggregator {
|
|
|
1299
1345
|
...(entry.toolMessageId
|
|
1300
1346
|
? { unstable_toolMessageId: entry.toolMessageId }
|
|
1301
1347
|
: {}),
|
|
1348
|
+
...(entry.resultMetadata !== undefined
|
|
1349
|
+
? {
|
|
1350
|
+
providerMetadata: withAgUiPartMessageMetadata(
|
|
1351
|
+
undefined,
|
|
1352
|
+
entry.resultMetadata,
|
|
1353
|
+
),
|
|
1354
|
+
}
|
|
1355
|
+
: {}),
|
|
1302
1356
|
...(nestedMessages.length > 0 ? { messages: nestedMessages } : {}),
|
|
1303
1357
|
} as ToolCallMessagePart & { unstable_toolMessageId?: string };
|
|
1304
1358
|
snapshot.push(toolPart);
|
|
@@ -4,7 +4,7 @@ import { createAgUiSubscriber } from "./subscriber";
|
|
|
4
4
|
const createSubscriber = () => {
|
|
5
5
|
const dispatch = vi.fn();
|
|
6
6
|
const logger = { debug: vi.fn(), error: vi.fn() };
|
|
7
|
-
const subscriber = createAgUiSubscriber({ dispatch,
|
|
7
|
+
const subscriber = createAgUiSubscriber({ dispatch, logger });
|
|
8
8
|
return { subscriber, dispatch, logger };
|
|
9
9
|
};
|
|
10
10
|
|
|
@@ -60,7 +60,6 @@ const ensureEvent = (
|
|
|
60
60
|
|
|
61
61
|
type SubscriberOptions = {
|
|
62
62
|
dispatch: Dispatch;
|
|
63
|
-
runId: string;
|
|
64
63
|
logger?: Logger;
|
|
65
64
|
onRunFailed?: (error: Error) => void;
|
|
66
65
|
};
|
|
@@ -68,7 +67,7 @@ type SubscriberOptions = {
|
|
|
68
67
|
export const createAgUiSubscriber = (
|
|
69
68
|
options: SubscriberOptions,
|
|
70
69
|
): Subscriber => {
|
|
71
|
-
const { dispatch,
|
|
70
|
+
const { dispatch, logger, onRunFailed } = options;
|
|
72
71
|
let runFinishedDispatched = false;
|
|
73
72
|
const dispatchIfValid = (raw: unknown, type: AgUiEvent["type"]) => {
|
|
74
73
|
const event = ensureEvent(raw, type, logger);
|
|
@@ -140,10 +139,6 @@ export const createAgUiSubscriber = (
|
|
|
140
139
|
logger?.debug?.("[agui] step boundary ignored", event),
|
|
141
140
|
onStepFinishedEvent: ({ event }) =>
|
|
142
141
|
logger?.debug?.("[agui] step boundary ignored", event),
|
|
143
|
-
onRunFinalized: () => {
|
|
144
|
-
if (runFinishedDispatched) return;
|
|
145
|
-
dispatch({ type: "RUN_FINISHED", runId });
|
|
146
|
-
},
|
|
147
142
|
onRunFailed: ({ error }) => {
|
|
148
143
|
if (runFinishedDispatched) return;
|
|
149
144
|
runFinishedDispatched = true;
|
|
@@ -344,6 +344,9 @@ export const parseAgUiEvent = (
|
|
|
344
344
|
messageId: getString("messageId"),
|
|
345
345
|
role: payload.role === "tool" ? "tool" : undefined,
|
|
346
346
|
mcpResult: parseMcpToolCallResult(payload, content),
|
|
347
|
+
metadata: isPlainObject(payload.metadata)
|
|
348
|
+
? payload.metadata
|
|
349
|
+
: undefined,
|
|
347
350
|
subagentRunId: getString("subagentRunId"),
|
|
348
351
|
},
|
|
349
352
|
);
|
package/src/runtime/types.ts
CHANGED
|
@@ -26,6 +26,10 @@ export const AG_UI_METADATA_NAMESPACE = "agui";
|
|
|
26
26
|
export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
|
|
27
27
|
export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
|
|
28
28
|
|
|
29
|
+
// Defensive guard against a malformed or cyclic parentToolCallId /
|
|
30
|
+
// parentSubagentRunId chain, not a realistic nesting depth.
|
|
31
|
+
export const MAX_SUBAGENT_DEPTH = 16;
|
|
32
|
+
|
|
29
33
|
export type AgUiOpaqueReasoning = {
|
|
30
34
|
id: string;
|
|
31
35
|
encryptedValue: string;
|
|
@@ -278,6 +282,7 @@ export type AgUiEvent =
|
|
|
278
282
|
content: string | ContentPart[];
|
|
279
283
|
role?: "tool";
|
|
280
284
|
mcpResult?: McpToolCallResult;
|
|
285
|
+
metadata?: Record<string, unknown>;
|
|
281
286
|
subagentRunId?: string;
|
|
282
287
|
}
|
|
283
288
|
| {
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, cleanup, render, waitFor } from "@testing-library/react";
|
|
4
|
+
import { HttpAgent, type RunAgentInput } from "@ag-ui/client";
|
|
5
|
+
import {
|
|
6
|
+
AssistantRuntimeProvider,
|
|
7
|
+
useCloudThreadListAdapter,
|
|
8
|
+
useRemoteThreadListRuntime,
|
|
9
|
+
} from "@assistant-ui/core/react";
|
|
10
|
+
import type { AssistantRuntime } from "@assistant-ui/core";
|
|
11
|
+
import { useAui, type AssistantClient } from "@assistant-ui/store";
|
|
12
|
+
import { AssistantCloud } from "assistant-cloud";
|
|
13
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
14
|
+
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
15
|
+
|
|
16
|
+
// The real AssistantCloud client runs against this in-memory server.
|
|
17
|
+
type Row = {
|
|
18
|
+
id: string;
|
|
19
|
+
parent_id: string | null;
|
|
20
|
+
format: string;
|
|
21
|
+
content: unknown;
|
|
22
|
+
external_id: string | null;
|
|
23
|
+
run?: { status: "running" | "ended"; last_heartbeat_ms_ago: number };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Call = { method: string; path: string; body: any };
|
|
27
|
+
|
|
28
|
+
const createServer = (
|
|
29
|
+
initialRows: Row[] = [],
|
|
30
|
+
heartbeatIntervalMs = 20_000,
|
|
31
|
+
) => {
|
|
32
|
+
const rows = [...initialRows];
|
|
33
|
+
const calls: Call[] = [];
|
|
34
|
+
let created = 0;
|
|
35
|
+
let leases = 0;
|
|
36
|
+
const at = new Date(0).toISOString();
|
|
37
|
+
const json = (value: unknown, status = 200) =>
|
|
38
|
+
new Response(JSON.stringify(value), {
|
|
39
|
+
status,
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
});
|
|
42
|
+
const fetchMock = vi.fn(
|
|
43
|
+
async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
44
|
+
const url = new URL(String(input));
|
|
45
|
+
const method = init?.method ?? "GET";
|
|
46
|
+
const path = url.pathname.replace(/^\/v1/, "");
|
|
47
|
+
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
|
48
|
+
calls.push({ method, path, body });
|
|
49
|
+
if (method === "GET" && path === "/threads") {
|
|
50
|
+
return json({
|
|
51
|
+
threads: rows.length
|
|
52
|
+
? [
|
|
53
|
+
{
|
|
54
|
+
id: "remote-1",
|
|
55
|
+
title: "t",
|
|
56
|
+
is_archived: false,
|
|
57
|
+
external_id: null,
|
|
58
|
+
metadata: null,
|
|
59
|
+
project_id: "p",
|
|
60
|
+
workspace_id: "w",
|
|
61
|
+
created_at: at,
|
|
62
|
+
updated_at: at,
|
|
63
|
+
last_message_at: at,
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
: [],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (method === "POST" && path === "/threads")
|
|
70
|
+
return json({ thread_id: "remote-1" });
|
|
71
|
+
if (method === "GET" && path === "/threads/remote-1/messages") {
|
|
72
|
+
return json({
|
|
73
|
+
messages: [...rows].reverse().map((row, height) => ({
|
|
74
|
+
...row,
|
|
75
|
+
height,
|
|
76
|
+
created_at: at,
|
|
77
|
+
updated_at: at,
|
|
78
|
+
run: row.run ?? null,
|
|
79
|
+
})),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (method === "POST" && path === "/threads/remote-1/messages") {
|
|
83
|
+
const id = `msg_${++created}`;
|
|
84
|
+
rows.push({
|
|
85
|
+
id,
|
|
86
|
+
parent_id: body.parent_id,
|
|
87
|
+
format: body.format,
|
|
88
|
+
content: body.content,
|
|
89
|
+
external_id: body.external_id ?? null,
|
|
90
|
+
...(body.open_run
|
|
91
|
+
? { run: { status: "running", last_heartbeat_ms_ago: 0 } }
|
|
92
|
+
: undefined),
|
|
93
|
+
});
|
|
94
|
+
return json({
|
|
95
|
+
message_id: id,
|
|
96
|
+
...(body.open_run
|
|
97
|
+
? {
|
|
98
|
+
lease_id: `lease_${++leases}`,
|
|
99
|
+
heartbeat_interval_ms: heartbeatIntervalMs,
|
|
100
|
+
}
|
|
101
|
+
: undefined),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const write = path.match(/^\/threads\/remote-1\/messages\/([^/]+)$/);
|
|
105
|
+
if (method === "PUT" && write) {
|
|
106
|
+
const row = rows.find((r) => r.id === write[1]);
|
|
107
|
+
if (row) row.content = body.content;
|
|
108
|
+
return json({});
|
|
109
|
+
}
|
|
110
|
+
if (/^\/threads\/remote-1\/runs\/[^/]+\/heartbeat$/.test(path))
|
|
111
|
+
return json({ heartbeat_interval_ms: heartbeatIntervalMs });
|
|
112
|
+
if (/^\/threads\/remote-1\/runs\/[^/]+\/end$/.test(path)) return json({});
|
|
113
|
+
return json({});
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
return { rows, calls, fetchMock };
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const createsWith = (calls: Call[], openRun: boolean) =>
|
|
120
|
+
calls.filter(
|
|
121
|
+
(c) =>
|
|
122
|
+
c.method === "POST" &&
|
|
123
|
+
c.path === "/threads/remote-1/messages" &&
|
|
124
|
+
!!c.body.open_run === openRun,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
type Subscriber = Record<
|
|
128
|
+
string,
|
|
129
|
+
((payload: { event: any }) => void) | undefined
|
|
130
|
+
>;
|
|
131
|
+
|
|
132
|
+
// An AG-UI agent whose runs are scripted: it streams one text message, under an id the backend picks, and finishes when the test says so.
|
|
133
|
+
class ScriptedAgent extends HttpAgent {
|
|
134
|
+
runs: { finish: () => void; input: RunAgentInput }[] = [];
|
|
135
|
+
constructor() {
|
|
136
|
+
super({ url: "http://agent.test" });
|
|
137
|
+
}
|
|
138
|
+
override async runAgent(
|
|
139
|
+
parameters?: Partial<RunAgentInput>,
|
|
140
|
+
subscriber?: Subscriber,
|
|
141
|
+
): Promise<any> {
|
|
142
|
+
const input = parameters as RunAgentInput;
|
|
143
|
+
const id = `agent-msg-${this.runs.length + 1}`;
|
|
144
|
+
const emit = (name: string, event: object) =>
|
|
145
|
+
subscriber?.[name]?.({ event });
|
|
146
|
+
emit("onRunStartedEvent", {
|
|
147
|
+
type: "RUN_STARTED",
|
|
148
|
+
threadId: input.threadId,
|
|
149
|
+
runId: input.runId,
|
|
150
|
+
});
|
|
151
|
+
emit("onTextMessageStartEvent", {
|
|
152
|
+
type: "TEXT_MESSAGE_START",
|
|
153
|
+
messageId: id,
|
|
154
|
+
role: "assistant",
|
|
155
|
+
});
|
|
156
|
+
emit("onTextMessageContentEvent", {
|
|
157
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
158
|
+
messageId: id,
|
|
159
|
+
delta: "partial",
|
|
160
|
+
});
|
|
161
|
+
await new Promise<void>((resolve) =>
|
|
162
|
+
this.runs.push({ finish: resolve, input }),
|
|
163
|
+
);
|
|
164
|
+
emit("onTextMessageContentEvent", {
|
|
165
|
+
type: "TEXT_MESSAGE_CONTENT",
|
|
166
|
+
messageId: id,
|
|
167
|
+
delta: " answer",
|
|
168
|
+
});
|
|
169
|
+
emit("onTextMessageEndEvent", { type: "TEXT_MESSAGE_END", messageId: id });
|
|
170
|
+
emit("onRunFinishedEvent", {
|
|
171
|
+
type: "RUN_FINISHED",
|
|
172
|
+
threadId: input.threadId,
|
|
173
|
+
runId: input.runId,
|
|
174
|
+
});
|
|
175
|
+
return { result: undefined, newMessages: [] };
|
|
176
|
+
}
|
|
177
|
+
override abortRun() {}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const renderApp = async (staleAfter?: number) => {
|
|
181
|
+
const cloud = new AssistantCloud({
|
|
182
|
+
baseUrl: "http://cloud.test",
|
|
183
|
+
apiKey: "key",
|
|
184
|
+
userId: "user",
|
|
185
|
+
workspaceId: "workspace",
|
|
186
|
+
telemetry: false,
|
|
187
|
+
});
|
|
188
|
+
const agent = new ScriptedAgent();
|
|
189
|
+
let aui!: AssistantClient;
|
|
190
|
+
let runtime!: AssistantRuntime;
|
|
191
|
+
const Capture = () => {
|
|
192
|
+
aui = useAui();
|
|
193
|
+
return null;
|
|
194
|
+
};
|
|
195
|
+
const App = () => {
|
|
196
|
+
const adapter = useCloudThreadListAdapter({
|
|
197
|
+
cloud,
|
|
198
|
+
...(staleAfter !== undefined ? { staleAfter } : undefined),
|
|
199
|
+
});
|
|
200
|
+
runtime = useRemoteThreadListRuntime({
|
|
201
|
+
adapter,
|
|
202
|
+
runtimeHook: function RuntimeHook() {
|
|
203
|
+
return useAgUiRuntime({ agent });
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return (
|
|
207
|
+
<AssistantRuntimeProvider runtime={runtime}>
|
|
208
|
+
<Capture />
|
|
209
|
+
</AssistantRuntimeProvider>
|
|
210
|
+
);
|
|
211
|
+
};
|
|
212
|
+
render(<App />);
|
|
213
|
+
await act(async () => {
|
|
214
|
+
await aui.threads.getLoadThreadsPromise();
|
|
215
|
+
});
|
|
216
|
+
return { agent, getAui: () => aui, getRuntime: () => runtime };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const assistantRow = (
|
|
220
|
+
last_heartbeat_ms_ago: number,
|
|
221
|
+
status: "running" | "ended" = "running",
|
|
222
|
+
): Row => ({
|
|
223
|
+
id: "a1",
|
|
224
|
+
parent_id: "u1",
|
|
225
|
+
format: "aui/v0",
|
|
226
|
+
content: {
|
|
227
|
+
role: "assistant",
|
|
228
|
+
content: [{ type: "text", text: "half an answer" }],
|
|
229
|
+
status: { type: "incomplete", reason: "cancelled" },
|
|
230
|
+
},
|
|
231
|
+
external_id: "a1",
|
|
232
|
+
run: { status, last_heartbeat_ms_ago },
|
|
233
|
+
});
|
|
234
|
+
const userRow: Row = {
|
|
235
|
+
id: "u1",
|
|
236
|
+
parent_id: null,
|
|
237
|
+
format: "aui/v0",
|
|
238
|
+
content: { role: "user", content: [{ type: "text", text: "hello" }] },
|
|
239
|
+
external_id: "u1",
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
let server: ReturnType<typeof createServer>;
|
|
243
|
+
const useServer = (rows?: Row[], heartbeatIntervalMs?: number) => {
|
|
244
|
+
server = createServer(rows, heartbeatIntervalMs);
|
|
245
|
+
vi.stubGlobal("fetch", server.fetchMock);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
beforeEach(() => {
|
|
249
|
+
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
250
|
+
});
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
cleanup();
|
|
253
|
+
vi.unstubAllGlobals();
|
|
254
|
+
vi.restoreAllMocks();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("useAgUiRuntime with Assistant Cloud run leases", () => {
|
|
258
|
+
it("opens a lease when the run starts, heartbeats on the server's interval, writes as its holder and ends it when the run finishes", async () => {
|
|
259
|
+
useServer([], 30);
|
|
260
|
+
const { agent, getAui } = await renderApp();
|
|
261
|
+
|
|
262
|
+
await act(async () => {
|
|
263
|
+
getAui().thread.append("hello");
|
|
264
|
+
});
|
|
265
|
+
await waitFor(() => expect(agent.runs).toHaveLength(1));
|
|
266
|
+
await waitFor(() =>
|
|
267
|
+
expect(createsWith(server.calls, true)).toHaveLength(1),
|
|
268
|
+
);
|
|
269
|
+
const open = createsWith(server.calls, true)[0]!;
|
|
270
|
+
expect(open.body).toMatchObject({
|
|
271
|
+
format: "aui/v0",
|
|
272
|
+
parent_id: "msg_1",
|
|
273
|
+
content: { role: "assistant" },
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
await waitFor(() =>
|
|
277
|
+
expect(
|
|
278
|
+
server.calls.filter((c) => c.path.endsWith("/runs/lease_1/heartbeat"))
|
|
279
|
+
.length,
|
|
280
|
+
).toBeGreaterThanOrEqual(2),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
await act(async () => {
|
|
284
|
+
agent.runs[0]!.finish();
|
|
285
|
+
});
|
|
286
|
+
await waitFor(() =>
|
|
287
|
+
expect(
|
|
288
|
+
server.calls.some(
|
|
289
|
+
(c) => c.method === "POST" && c.path.endsWith("/runs/lease_1/end"),
|
|
290
|
+
),
|
|
291
|
+
).toBe(true),
|
|
292
|
+
);
|
|
293
|
+
const end = server.calls.find((c) => c.path.endsWith("/runs/lease_1/end"))!;
|
|
294
|
+
expect(end.body).toEqual({ reason: "done" });
|
|
295
|
+
const final = server.calls.filter(
|
|
296
|
+
(c) => c.method === "PUT" && c.body.lease_id === "lease_1",
|
|
297
|
+
);
|
|
298
|
+
expect(final.length).toBeGreaterThan(0);
|
|
299
|
+
expect(JSON.stringify(final.at(-1)!.body.content)).toContain(
|
|
300
|
+
"partial answer",
|
|
301
|
+
);
|
|
302
|
+
expect(createsWith(server.calls, true)).toHaveLength(1);
|
|
303
|
+
const reply = getAui().thread.getState().messages[1]!;
|
|
304
|
+
expect(reply.id).toBe("agent-msg-1");
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("shows a reloaded run quiet for longer than staleAfter as interrupted; Resume posts a sibling with its own lease and runs the agent again", async () => {
|
|
308
|
+
useServer([userRow, assistantRow(45_000)]);
|
|
309
|
+
const { agent, getAui, getRuntime } = await renderApp();
|
|
310
|
+
await act(async () => {
|
|
311
|
+
await getRuntime().threads.switchToThread("remote-1");
|
|
312
|
+
});
|
|
313
|
+
await waitFor(() =>
|
|
314
|
+
expect(getAui().thread.getState().messages).toHaveLength(2),
|
|
315
|
+
);
|
|
316
|
+
const stale = getAui().thread.message({ index: 1 });
|
|
317
|
+
await waitFor(() => expect(stale.getState().runState).toBe("stale"));
|
|
318
|
+
|
|
319
|
+
await act(async () => {
|
|
320
|
+
stale.reload();
|
|
321
|
+
});
|
|
322
|
+
await waitFor(() => expect(agent.runs).toHaveLength(1));
|
|
323
|
+
await waitFor(() =>
|
|
324
|
+
expect(createsWith(server.calls, true)).toHaveLength(1),
|
|
325
|
+
);
|
|
326
|
+
expect(createsWith(server.calls, true)[0]!.body).toMatchObject({
|
|
327
|
+
parent_id: "u1",
|
|
328
|
+
open_run: true,
|
|
329
|
+
});
|
|
330
|
+
await act(async () => {
|
|
331
|
+
agent.runs[0]!.finish();
|
|
332
|
+
});
|
|
333
|
+
await waitFor(() =>
|
|
334
|
+
expect(
|
|
335
|
+
server.calls.some((c) => c.path.endsWith("/runs/lease_1/end")),
|
|
336
|
+
).toBe(true),
|
|
337
|
+
);
|
|
338
|
+
expect(
|
|
339
|
+
server.calls.some((c) => c.method === "PUT" && c.path.endsWith("/a1")),
|
|
340
|
+
).toBe(false);
|
|
341
|
+
const messages = getAui().thread.getState().messages;
|
|
342
|
+
expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]);
|
|
343
|
+
expect(messages[1]!.id).not.toBe("a1");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("shows a reloaded run with a recent beat as in progress, and staleAfter moves the line", async () => {
|
|
347
|
+
useServer([userRow, assistantRow(5_000)]);
|
|
348
|
+
const { getAui, getRuntime } = await renderApp();
|
|
349
|
+
await act(async () => {
|
|
350
|
+
await getRuntime().threads.switchToThread("remote-1");
|
|
351
|
+
});
|
|
352
|
+
await waitFor(() =>
|
|
353
|
+
expect(getAui().thread.message({ index: 1 }).getState().runState).toBe(
|
|
354
|
+
"in-progress",
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
cleanup();
|
|
358
|
+
|
|
359
|
+
useServer([userRow, assistantRow(5_000)]);
|
|
360
|
+
const short = await renderApp(2_000);
|
|
361
|
+
await act(async () => {
|
|
362
|
+
await short.getRuntime().threads.switchToThread("remote-1");
|
|
363
|
+
});
|
|
364
|
+
await waitFor(() =>
|
|
365
|
+
expect(
|
|
366
|
+
short.getAui().thread.message({ index: 1 }).getState().runState,
|
|
367
|
+
).toBe("stale"),
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("loads an ended run as a plain message", async () => {
|
|
372
|
+
useServer([userRow, assistantRow(90_000, "ended")]);
|
|
373
|
+
const { getAui, getRuntime } = await renderApp();
|
|
374
|
+
await act(async () => {
|
|
375
|
+
await getRuntime().threads.switchToThread("remote-1");
|
|
376
|
+
});
|
|
377
|
+
await waitFor(() =>
|
|
378
|
+
expect(getAui().thread.getState().messages).toHaveLength(2),
|
|
379
|
+
);
|
|
380
|
+
expect(
|
|
381
|
+
getAui().thread.message({ index: 1 }).getState().runState,
|
|
382
|
+
).toBeUndefined();
|
|
383
|
+
});
|
|
384
|
+
});
|