@assistant-ui/react-ag-ui 0.0.56 → 0.0.57
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/AgUiThreadRuntimeCore.d.ts +2 -13
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +106 -193
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/conversions.d.ts.map +1 -1
- package/dist/runtime/adapter/conversions.js +20 -14
- package/dist/runtime/adapter/conversions.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +18 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +130 -41
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/useAgUiRuntime.js +11 -9
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +8 -8
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +105 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +152 -261
- package/src/runtime/adapter/conversions.ts +41 -19
- package/src/runtime/adapter/run-aggregator.ts +198 -47
- package/src/useAgUiRuntime.history.test.tsx +76 -0
- package/src/useAgUiRuntime.ts +11 -9
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { InputContent, RunAgentParameters } from "@ag-ui/client";
|
|
4
|
+
import { generateId } from "@assistant-ui/core";
|
|
4
5
|
import type {
|
|
5
6
|
ThreadMessageLike as CoreThreadMessageLike,
|
|
6
7
|
PartProviderMetadata,
|
|
@@ -9,8 +10,8 @@ import type {
|
|
|
9
10
|
} from "@assistant-ui/core";
|
|
10
11
|
import {
|
|
11
12
|
getAutoStatus,
|
|
12
|
-
httpUrlPattern,
|
|
13
13
|
parseDataUrl,
|
|
14
|
+
resolveFilePartSource,
|
|
14
15
|
} from "@assistant-ui/core/internal";
|
|
15
16
|
import { type Tool, toToolsJSONSchema } from "assistant-stream";
|
|
16
17
|
import type { ReadonlyJSONObject } from "assistant-stream/utils";
|
|
@@ -177,13 +178,6 @@ function parseJSONText(value: string): unknown {
|
|
|
177
178
|
}
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
function generateId(): string {
|
|
181
|
-
return (
|
|
182
|
-
(globalThis.crypto as { randomUUID?: () => string })?.randomUUID?.() ??
|
|
183
|
-
Math.random().toString(36).slice(2)
|
|
184
|
-
);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
181
|
function normalizeToolCall(part: ToolCallPart): {
|
|
188
182
|
id: string;
|
|
189
183
|
call: AgUiToolCall;
|
|
@@ -242,17 +236,20 @@ function buildInputSource(
|
|
|
242
236
|
declaredMimeType: string | undefined,
|
|
243
237
|
sourceType?: string,
|
|
244
238
|
): InputContentSource {
|
|
245
|
-
|
|
239
|
+
const source = resolveFilePartSource({
|
|
240
|
+
data: value,
|
|
241
|
+
mimeType: declaredMimeType ?? "application/octet-stream",
|
|
242
|
+
sourceType,
|
|
243
|
+
});
|
|
244
|
+
if (source.kind === "url") {
|
|
246
245
|
return declaredMimeType !== undefined
|
|
247
|
-
? { type: "url", value, mimeType: declaredMimeType }
|
|
248
|
-
: { type: "url", value };
|
|
246
|
+
? { type: "url", value: source.url, mimeType: declaredMimeType }
|
|
247
|
+
: { type: "url", value: source.url };
|
|
249
248
|
}
|
|
250
|
-
const parsed = parseDataUrl(value);
|
|
251
249
|
return {
|
|
252
250
|
type: "data",
|
|
253
|
-
value:
|
|
254
|
-
mimeType:
|
|
255
|
-
parsed?.mimeType ?? declaredMimeType ?? "application/octet-stream",
|
|
251
|
+
value: source.data,
|
|
252
|
+
mimeType: source.mimeType,
|
|
256
253
|
};
|
|
257
254
|
}
|
|
258
255
|
|
|
@@ -662,7 +659,7 @@ function toAssistantSnapshotMessage(
|
|
|
662
659
|
}
|
|
663
660
|
|
|
664
661
|
function toUserOrSystemSnapshotMessage(
|
|
665
|
-
role: "user" | "system",
|
|
662
|
+
role: "user" | "system" | "developer",
|
|
666
663
|
rawMessage: Record<string, unknown>,
|
|
667
664
|
): CoreThreadMessageLike {
|
|
668
665
|
const messageName = getString(rawMessage, "name");
|
|
@@ -670,10 +667,23 @@ function toUserOrSystemSnapshotMessage(
|
|
|
670
667
|
role === "user" ? toSnapshotAttachments(rawMessage.content) : [];
|
|
671
668
|
return {
|
|
672
669
|
id: getString(rawMessage, "id") ?? generateId(),
|
|
673
|
-
role
|
|
670
|
+
// The internal message model has no developer role; it rides as a system
|
|
671
|
+
// message with its wire role kept in metadata so the export restores it.
|
|
672
|
+
role: role === "developer" ? "system" : role,
|
|
674
673
|
content: extractText(rawMessage.content),
|
|
675
674
|
...(messageName !== undefined ? { name: messageName } : {}),
|
|
676
675
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
676
|
+
...(role === "developer"
|
|
677
|
+
? {
|
|
678
|
+
metadata: {
|
|
679
|
+
custom: {
|
|
680
|
+
[AG_UI_METADATA_NAMESPACE]: {
|
|
681
|
+
role: "developer",
|
|
682
|
+
} satisfies AgUiCustomMetadata,
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
}
|
|
686
|
+
: {}),
|
|
677
687
|
};
|
|
678
688
|
}
|
|
679
689
|
|
|
@@ -939,7 +949,7 @@ export function fromAgUiMessages(
|
|
|
939
949
|
continue;
|
|
940
950
|
}
|
|
941
951
|
|
|
942
|
-
if (role === "user" || role === "system") {
|
|
952
|
+
if (role === "user" || role === "system" || role === "developer") {
|
|
943
953
|
converted.push(toUserOrSystemSnapshotMessage(role, rawMessage));
|
|
944
954
|
}
|
|
945
955
|
}
|
|
@@ -1137,9 +1147,21 @@ export function toAgUiMessages(
|
|
|
1137
1147
|
}
|
|
1138
1148
|
|
|
1139
1149
|
if (message.role === "system" || message.role === "developer") {
|
|
1150
|
+
const custom = isObject(message.metadata)
|
|
1151
|
+
? message.metadata.custom
|
|
1152
|
+
: undefined;
|
|
1153
|
+
const namespaced = isObject(custom)
|
|
1154
|
+
? custom[AG_UI_METADATA_NAMESPACE]
|
|
1155
|
+
: undefined;
|
|
1156
|
+
const wireRole =
|
|
1157
|
+
message.role === "system" &&
|
|
1158
|
+
isObject(namespaced) &&
|
|
1159
|
+
namespaced.role === "developer"
|
|
1160
|
+
? ("developer" as const)
|
|
1161
|
+
: message.role;
|
|
1140
1162
|
converted.push({
|
|
1141
1163
|
id: message.id,
|
|
1142
|
-
role:
|
|
1164
|
+
role: wireRole,
|
|
1143
1165
|
content: extractText(message.content),
|
|
1144
1166
|
...(message.name ? { name: message.name } : {}),
|
|
1145
1167
|
});
|
|
@@ -28,6 +28,9 @@ export type AgUiOpaqueReasoning = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
export type AgUiCustomMetadata = {
|
|
31
|
+
/** Wire role restored on export for messages the internal model cannot
|
|
32
|
+
* represent (a developer record rides as a system message). */
|
|
33
|
+
role?: "developer";
|
|
31
34
|
interrupts?: AgUiInterrupt[];
|
|
32
35
|
opaqueReasoning?: AgUiOpaqueReasoning[];
|
|
33
36
|
};
|
|
@@ -63,6 +66,7 @@ export type RunAggregatorOptions = {
|
|
|
63
66
|
logger: Logger;
|
|
64
67
|
emit: Emit;
|
|
65
68
|
onServerMessageId?: (messageId: string) => void;
|
|
69
|
+
onTextMessageStart?: (messageId: string) => void;
|
|
66
70
|
};
|
|
67
71
|
|
|
68
72
|
/**
|
|
@@ -80,16 +84,29 @@ export class RunAggregator {
|
|
|
80
84
|
private readonly showThinking: boolean;
|
|
81
85
|
private readonly logger: Logger;
|
|
82
86
|
private readonly onServerMessageId: ((messageId: string) => void) | undefined;
|
|
87
|
+
private readonly onTextMessageStart:
|
|
88
|
+
| ((messageId: string) => void)
|
|
89
|
+
| undefined;
|
|
83
90
|
|
|
84
91
|
private status: ChatModelRunResult["status"] | undefined;
|
|
85
92
|
private interrupts: AgUiInterrupt[] | undefined;
|
|
86
|
-
private readonly textParts = new Map<
|
|
87
|
-
string,
|
|
88
|
-
{ buffer: string; touched: boolean }
|
|
89
|
-
>();
|
|
93
|
+
private readonly textParts = new Map<string, { buffer: string }>();
|
|
90
94
|
private activeTextMessageId: string | undefined;
|
|
91
95
|
private readonly reasoningParts = new Map<string, string>(); // key → buffer
|
|
92
96
|
private readonly reasoningSignatures = new Map<string, string>();
|
|
97
|
+
private readonly reasoningSignatureIds = new Map<string, string>();
|
|
98
|
+
// Signatures captured while thinking is hidden have no block to live on;
|
|
99
|
+
// they are transport state that must survive to the next run input. The
|
|
100
|
+
// claim rules mirror the visible path: an id-carrying block, or an open
|
|
101
|
+
// anonymous one.
|
|
102
|
+
private readonly hiddenSignatures = new Map<string, string>();
|
|
103
|
+
private readonly hiddenSignatureAnchors = new Map<string, number>();
|
|
104
|
+
private readonly hiddenBlockAnchors = new Map<string, number>();
|
|
105
|
+
private hiddenAnonymousAnchor: number | undefined;
|
|
106
|
+
private readonly hiddenReasoningIds = new Set<string>();
|
|
107
|
+
private hiddenActiveReasoning: "none" | "anonymous" | "identified" = "none";
|
|
108
|
+
private hasEmittedOpaqueReasoning = false;
|
|
109
|
+
private readonly loggedDroppedOpaqueIds = new Set<string>();
|
|
93
110
|
private readonly reasoningMessageIds = new Map<string, string>();
|
|
94
111
|
private readonly anonymousReasoningKeys = new Set<string>();
|
|
95
112
|
private activeReasoningKey: string | undefined;
|
|
@@ -106,6 +123,8 @@ export class RunAggregator {
|
|
|
106
123
|
)[] = [];
|
|
107
124
|
private textPartCounter = 0;
|
|
108
125
|
private serverMessageIdReported = false;
|
|
126
|
+
private reportedServerMessageId: string | undefined;
|
|
127
|
+
private lastTextMessageId: string | undefined;
|
|
109
128
|
|
|
110
129
|
private streamStartTime: number | undefined;
|
|
111
130
|
private firstTokenTime: number | undefined;
|
|
@@ -116,6 +135,7 @@ export class RunAggregator {
|
|
|
116
135
|
this.showThinking = options.showThinking;
|
|
117
136
|
this.logger = options.logger;
|
|
118
137
|
this.onServerMessageId = options.onServerMessageId;
|
|
138
|
+
this.onTextMessageStart = options.onTextMessageStart;
|
|
119
139
|
}
|
|
120
140
|
|
|
121
141
|
hasToolCall(toolCallId: string): boolean {
|
|
@@ -125,22 +145,11 @@ export class RunAggregator {
|
|
|
125
145
|
handle(event: AgUiEvent): void {
|
|
126
146
|
switch (event.type) {
|
|
127
147
|
case "RUN_STARTED": {
|
|
128
|
-
this.
|
|
129
|
-
this.reasoningParts.clear();
|
|
130
|
-
this.reasoningSignatures.clear();
|
|
131
|
-
this.reasoningMessageIds.clear();
|
|
132
|
-
this.anonymousReasoningKeys.clear();
|
|
133
|
-
this.activeReasoningKey = undefined;
|
|
134
|
-
this.reasoningPartCounter = 0;
|
|
135
|
-
this.toolCalls.clear();
|
|
136
|
-
this.a2uiBuckets.clear();
|
|
137
|
-
this.a2uiToolCallIds.clear();
|
|
138
|
-
this.lastResolvedToolCallId = undefined;
|
|
139
|
-
this.partOrder.length = 0;
|
|
140
|
-
this.textPartCounter = 0;
|
|
141
|
-
this.activeTextMessageId = undefined;
|
|
148
|
+
this.resetMessageParts();
|
|
142
149
|
this.interrupts = undefined;
|
|
143
150
|
this.serverMessageIdReported = false;
|
|
151
|
+
this.lastTextMessageId = undefined;
|
|
152
|
+
this.reportedServerMessageId = undefined;
|
|
144
153
|
this.streamStartTime = Date.now();
|
|
145
154
|
this.firstTokenTime = undefined;
|
|
146
155
|
this.totalChunks = 0;
|
|
@@ -184,18 +193,19 @@ export class RunAggregator {
|
|
|
184
193
|
}
|
|
185
194
|
|
|
186
195
|
case "TEXT_MESSAGE_START": {
|
|
196
|
+
this.beginDistinctTextMessage(event.messageId);
|
|
187
197
|
this.reportServerMessageId(event.messageId);
|
|
188
|
-
|
|
189
|
-
if (
|
|
190
|
-
this.markTextPartTouched(id);
|
|
191
|
-
}
|
|
198
|
+
this.startTextMessage(event.messageId);
|
|
199
|
+
if (event.messageId) this.lastTextMessageId = event.messageId;
|
|
192
200
|
this.emit();
|
|
193
201
|
break;
|
|
194
202
|
}
|
|
195
203
|
case "TEXT_MESSAGE_CONTENT":
|
|
196
204
|
case "TEXT_MESSAGE_CHUNK": {
|
|
197
205
|
const incomingId = "messageId" in event ? event.messageId : undefined;
|
|
206
|
+
this.beginDistinctTextMessage(incomingId);
|
|
198
207
|
this.reportServerMessageId(incomingId);
|
|
208
|
+
if (incomingId) this.lastTextMessageId = incomingId;
|
|
199
209
|
if (!event.delta) break;
|
|
200
210
|
this.recordFirstToken();
|
|
201
211
|
const id = this.resolveTextMessageId(incomingId);
|
|
@@ -238,13 +248,31 @@ export class RunAggregator {
|
|
|
238
248
|
// entityId names any message, not necessarily a reasoning one, so an
|
|
239
249
|
// unmatched id may only claim a block that has no id to contradict it.
|
|
240
250
|
const active = this.activeReasoningKey;
|
|
241
|
-
const key = this.
|
|
242
|
-
? event.entityId
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
251
|
+
const key = this.showThinking
|
|
252
|
+
? this.reasoningParts.has(event.entityId)
|
|
253
|
+
? event.entityId
|
|
254
|
+
: active !== undefined && this.anonymousReasoningKeys.has(active)
|
|
255
|
+
? active
|
|
256
|
+
: undefined
|
|
257
|
+
: undefined;
|
|
246
258
|
if (key !== undefined) {
|
|
247
259
|
this.reasoningSignatures.set(key, event.encryptedValue);
|
|
260
|
+
this.reasoningSignatureIds.set(key, event.entityId);
|
|
261
|
+
this.emit();
|
|
262
|
+
} else if (
|
|
263
|
+
!this.showThinking &&
|
|
264
|
+
event.entityId.trim().length > 0 &&
|
|
265
|
+
event.encryptedValue.trim().length > 0 &&
|
|
266
|
+
(this.hiddenReasoningIds.has(event.entityId) ||
|
|
267
|
+
this.hiddenActiveReasoning === "anonymous")
|
|
268
|
+
) {
|
|
269
|
+
this.hiddenSignatures.set(event.entityId, event.encryptedValue);
|
|
270
|
+
this.hiddenSignatureAnchors.set(
|
|
271
|
+
event.entityId,
|
|
272
|
+
this.hiddenBlockAnchors.get(event.entityId) ??
|
|
273
|
+
this.hiddenAnonymousAnchor ??
|
|
274
|
+
this.partOrder.length,
|
|
275
|
+
);
|
|
248
276
|
this.emit();
|
|
249
277
|
}
|
|
250
278
|
}
|
|
@@ -436,8 +464,13 @@ export class RunAggregator {
|
|
|
436
464
|
}
|
|
437
465
|
|
|
438
466
|
private reportServerMessageId(messageId: string | undefined): void {
|
|
439
|
-
if (
|
|
467
|
+
if (!messageId) return;
|
|
468
|
+
if (this.lastTextMessageId === undefined) {
|
|
469
|
+
this.lastTextMessageId = messageId;
|
|
470
|
+
}
|
|
471
|
+
if (this.serverMessageIdReported) return;
|
|
440
472
|
this.serverMessageIdReported = true;
|
|
473
|
+
this.reportedServerMessageId = messageId;
|
|
441
474
|
this.onServerMessageId?.(messageId);
|
|
442
475
|
}
|
|
443
476
|
|
|
@@ -445,6 +478,45 @@ export class RunAggregator {
|
|
|
445
478
|
this.textParts.clear();
|
|
446
479
|
}
|
|
447
480
|
|
|
481
|
+
private resetMessageParts(): void {
|
|
482
|
+
this.clearTextParts();
|
|
483
|
+
this.reasoningParts.clear();
|
|
484
|
+
this.reasoningSignatures.clear();
|
|
485
|
+
this.reasoningSignatureIds.clear();
|
|
486
|
+
this.reasoningMessageIds.clear();
|
|
487
|
+
this.hiddenSignatures.clear();
|
|
488
|
+
this.hiddenSignatureAnchors.clear();
|
|
489
|
+
this.hiddenBlockAnchors.clear();
|
|
490
|
+
this.hiddenAnonymousAnchor = undefined;
|
|
491
|
+
this.hiddenReasoningIds.clear();
|
|
492
|
+
this.hiddenActiveReasoning = "none";
|
|
493
|
+
this.loggedDroppedOpaqueIds.clear();
|
|
494
|
+
this.anonymousReasoningKeys.clear();
|
|
495
|
+
this.activeReasoningKey = undefined;
|
|
496
|
+
this.reasoningPartCounter = 0;
|
|
497
|
+
this.toolCalls.clear();
|
|
498
|
+
this.a2uiBuckets.clear();
|
|
499
|
+
this.a2uiToolCallIds.clear();
|
|
500
|
+
this.lastResolvedToolCallId = undefined;
|
|
501
|
+
this.partOrder.length = 0;
|
|
502
|
+
this.textPartCounter = 0;
|
|
503
|
+
this.activeTextMessageId = undefined;
|
|
504
|
+
this.reportedServerMessageId = undefined;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private beginDistinctTextMessage(messageId: string | undefined): void {
|
|
508
|
+
if (
|
|
509
|
+
!messageId ||
|
|
510
|
+
this.lastTextMessageId === undefined ||
|
|
511
|
+
this.lastTextMessageId === messageId ||
|
|
512
|
+
!this.onTextMessageStart
|
|
513
|
+
) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.resetMessageParts();
|
|
517
|
+
this.onTextMessageStart(messageId);
|
|
518
|
+
}
|
|
519
|
+
|
|
448
520
|
private generateTextKey(): string {
|
|
449
521
|
this.textPartCounter += 1;
|
|
450
522
|
return `text-${this.textPartCounter}`;
|
|
@@ -476,7 +548,7 @@ export class RunAggregator {
|
|
|
476
548
|
|
|
477
549
|
private ensureTextPart(id: string): void {
|
|
478
550
|
if (!this.textParts.has(id)) {
|
|
479
|
-
this.textParts.set(id, { buffer: ""
|
|
551
|
+
this.textParts.set(id, { buffer: "" });
|
|
480
552
|
if (
|
|
481
553
|
!this.partOrder.some((part) => part.kind === "text" && part.key === id)
|
|
482
554
|
) {
|
|
@@ -485,18 +557,11 @@ export class RunAggregator {
|
|
|
485
557
|
}
|
|
486
558
|
}
|
|
487
559
|
|
|
488
|
-
private markTextPartTouched(id: string): void {
|
|
489
|
-
const entry = this.textParts.get(id);
|
|
490
|
-
if (!entry) return;
|
|
491
|
-
entry.touched = true;
|
|
492
|
-
}
|
|
493
|
-
|
|
494
560
|
private appendText(id: string, delta: string): void {
|
|
495
561
|
this.ensureTextPart(id);
|
|
496
562
|
const entry = this.textParts.get(id);
|
|
497
563
|
if (!entry) return;
|
|
498
564
|
entry.buffer += delta;
|
|
499
|
-
entry.touched = true;
|
|
500
565
|
}
|
|
501
566
|
|
|
502
567
|
private startToolCall(
|
|
@@ -640,7 +705,22 @@ export class RunAggregator {
|
|
|
640
705
|
new Set(this.toolCalls.keys()),
|
|
641
706
|
);
|
|
642
707
|
|
|
643
|
-
|
|
708
|
+
const opaqueCandidates: (AgUiOpaqueReasoning & { anchor: number })[] =
|
|
709
|
+
Array.from(this.hiddenSignatures, ([id, encryptedValue]) => ({
|
|
710
|
+
id,
|
|
711
|
+
encryptedValue,
|
|
712
|
+
anchor: this.hiddenSignatureAnchors.get(id)!,
|
|
713
|
+
}));
|
|
714
|
+
|
|
715
|
+
let currentIndex = -1;
|
|
716
|
+
let lastMaterializedIndex = -1;
|
|
717
|
+
const pushSnapshotPart = (part: (typeof snapshot)[number]) => {
|
|
718
|
+
snapshot.push(part);
|
|
719
|
+
lastMaterializedIndex = currentIndex;
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
for (const [index, part] of this.partOrder.entries()) {
|
|
723
|
+
currentIndex = index;
|
|
644
724
|
if (part.kind === "reasoning") {
|
|
645
725
|
if (this.showThinking) {
|
|
646
726
|
const buffer = this.reasoningParts.get(part.key) ?? "";
|
|
@@ -651,13 +731,22 @@ export class RunAggregator {
|
|
|
651
731
|
...(reasoningId !== undefined ? { reasoningId } : {}),
|
|
652
732
|
...(encryptedValue !== undefined ? { encryptedValue } : {}),
|
|
653
733
|
};
|
|
654
|
-
|
|
734
|
+
pushSnapshotPart({
|
|
655
735
|
type: "reasoning",
|
|
656
736
|
text: buffer,
|
|
657
737
|
...(Object.keys(meta).length > 0
|
|
658
738
|
? { providerMetadata: { [AG_UI_METADATA_NAMESPACE]: meta } }
|
|
659
739
|
: {}),
|
|
660
740
|
} as const);
|
|
741
|
+
} else {
|
|
742
|
+
// A retracted empty block still carries transport state: without
|
|
743
|
+
// a part to live on, its signature rides the message metadata,
|
|
744
|
+
// matching the shape a snapshot reload produces.
|
|
745
|
+
const encryptedValue = this.reasoningSignatures.get(part.key);
|
|
746
|
+
const id = this.reasoningSignatureIds.get(part.key);
|
|
747
|
+
if (id?.trim() && encryptedValue?.trim()) {
|
|
748
|
+
opaqueCandidates.push({ id, encryptedValue, anchor: index + 1 });
|
|
749
|
+
}
|
|
661
750
|
}
|
|
662
751
|
}
|
|
663
752
|
continue;
|
|
@@ -665,14 +754,14 @@ export class RunAggregator {
|
|
|
665
754
|
|
|
666
755
|
if (part.kind === "text") {
|
|
667
756
|
const entry = this.textParts.get(part.key);
|
|
668
|
-
if (entry
|
|
669
|
-
|
|
757
|
+
if (entry && entry.buffer.trim().length > 0) {
|
|
758
|
+
pushSnapshotPart({ type: "text", text: entry.buffer } as const);
|
|
670
759
|
}
|
|
671
760
|
continue;
|
|
672
761
|
}
|
|
673
762
|
|
|
674
763
|
if (part.kind === "data") {
|
|
675
|
-
|
|
764
|
+
pushSnapshotPart({
|
|
676
765
|
type: "data",
|
|
677
766
|
name: part.name,
|
|
678
767
|
data: part.value,
|
|
@@ -712,17 +801,57 @@ export class RunAggregator {
|
|
|
712
801
|
? { unstable_toolMessageId: entry.toolMessageId }
|
|
713
802
|
: {}),
|
|
714
803
|
} as ToolCallMessagePart & { unstable_toolMessageId?: string };
|
|
715
|
-
|
|
804
|
+
pushSnapshotPart(toolPart);
|
|
716
805
|
}
|
|
717
806
|
|
|
807
|
+
// An anonymous claim promotes the signature's entityId to a wire message
|
|
808
|
+
// id; when that id actually names a text message or the adopted assistant
|
|
809
|
+
// message id (which can also arrive via TOOL_CALL_START.parentMessageId),
|
|
810
|
+
// replaying it as its own record would put two wire records under one id,
|
|
811
|
+
// so such entries are dropped instead — the same outcome the visible
|
|
812
|
+
// path's claim guard produces for a signature that is "not for this
|
|
813
|
+
// block". Entries with no materialized part after their anchor trail the
|
|
814
|
+
// assistant record, matching the snapshot path's anchor/after
|
|
815
|
+
// bookkeeping.
|
|
816
|
+
const toolMessageIds = new Set<string>();
|
|
817
|
+
for (const call of this.toolCalls.values()) {
|
|
818
|
+
if (call.toolMessageId) toolMessageIds.add(call.toolMessageId);
|
|
819
|
+
}
|
|
820
|
+
opaqueCandidates.sort((a, b) => a.anchor - b.anchor);
|
|
821
|
+
const publishableOpaqueReasoning = opaqueCandidates
|
|
822
|
+
.filter((entry) => {
|
|
823
|
+
const collides =
|
|
824
|
+
this.textParts.has(entry.id) ||
|
|
825
|
+
entry.id === this.reportedServerMessageId ||
|
|
826
|
+
toolMessageIds.has(entry.id);
|
|
827
|
+
if (collides && !this.loggedDroppedOpaqueIds.has(entry.id)) {
|
|
828
|
+
this.loggedDroppedOpaqueIds.add(entry.id);
|
|
829
|
+
this.logger.debug?.(
|
|
830
|
+
"[agui] aggregator dropped opaque reasoning signature: id collides with a message id",
|
|
831
|
+
entry.id,
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
return !collides;
|
|
835
|
+
})
|
|
836
|
+
.map(({ anchor, ...entry }) =>
|
|
837
|
+
lastMaterializedIndex < anchor ? { ...entry, after: true } : entry,
|
|
838
|
+
);
|
|
839
|
+
if (publishableOpaqueReasoning.length > 0)
|
|
840
|
+
this.hasEmittedOpaqueReasoning = true;
|
|
841
|
+
// Once an opaque entry has been published this run, the key keeps being
|
|
842
|
+
// emitted (empty when withdrawn) so the namespace merge can retract it.
|
|
843
|
+
const includeOpaque = this.hasEmittedOpaqueReasoning;
|
|
718
844
|
const timing = this.getTiming();
|
|
719
845
|
const metadata = {
|
|
720
846
|
...(timing ? { timing } : {}),
|
|
721
|
-
...(this.interrupts
|
|
847
|
+
...(this.interrupts || includeOpaque
|
|
722
848
|
? {
|
|
723
849
|
custom: {
|
|
724
850
|
[AG_UI_METADATA_NAMESPACE]: {
|
|
725
|
-
interrupts: this.interrupts,
|
|
851
|
+
...(this.interrupts ? { interrupts: this.interrupts } : {}),
|
|
852
|
+
...(includeOpaque
|
|
853
|
+
? { opaqueReasoning: publishableOpaqueReasoning }
|
|
854
|
+
: {}),
|
|
726
855
|
} satisfies AgUiCustomMetadata,
|
|
727
856
|
},
|
|
728
857
|
}
|
|
@@ -778,7 +907,18 @@ export class RunAggregator {
|
|
|
778
907
|
}
|
|
779
908
|
|
|
780
909
|
private handleReasoningStart(messageId?: string, isMessageId = false): void {
|
|
781
|
-
if (!this.showThinking)
|
|
910
|
+
if (!this.showThinking) {
|
|
911
|
+
const anchor = this.partOrder.length;
|
|
912
|
+
if (messageId === undefined) {
|
|
913
|
+
this.hiddenActiveReasoning = "anonymous";
|
|
914
|
+
this.hiddenAnonymousAnchor = anchor;
|
|
915
|
+
} else {
|
|
916
|
+
this.hiddenReasoningIds.add(messageId);
|
|
917
|
+
this.hiddenBlockAnchors.set(messageId, anchor);
|
|
918
|
+
this.hiddenActiveReasoning = "identified";
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
782
922
|
// A reasoning block acts as a boundary: anonymous text arriving after it
|
|
783
923
|
// should be a new part, not appended to any pre-reasoning text.
|
|
784
924
|
this.activeTextMessageId = undefined;
|
|
@@ -804,7 +944,15 @@ export class RunAggregator {
|
|
|
804
944
|
messageId?: string,
|
|
805
945
|
isMessageId = false,
|
|
806
946
|
): void {
|
|
807
|
-
if (!
|
|
947
|
+
if (!delta) return;
|
|
948
|
+
if (!this.showThinking) {
|
|
949
|
+
// Content without a preceding START still names the block (the visible
|
|
950
|
+
// path opens it lazily); register it so a signature can claim it.
|
|
951
|
+
if (this.hiddenActiveReasoning === "none") {
|
|
952
|
+
this.handleReasoningStart(messageId, isMessageId);
|
|
953
|
+
}
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
808
956
|
if (!this.activeReasoningKey) {
|
|
809
957
|
// Content arrived without a preceding START — create the slot lazily.
|
|
810
958
|
this.handleReasoningStart(messageId, isMessageId);
|
|
@@ -816,7 +964,10 @@ export class RunAggregator {
|
|
|
816
964
|
}
|
|
817
965
|
|
|
818
966
|
private handleReasoningEnd(): void {
|
|
819
|
-
if (!this.showThinking)
|
|
967
|
+
if (!this.showThinking) {
|
|
968
|
+
this.hiddenActiveReasoning = "none";
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
820
971
|
this.activeReasoningKey = undefined;
|
|
821
972
|
this.emit();
|
|
822
973
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
+
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
|
5
|
+
import type { ThreadHistoryAdapter } from "@assistant-ui/core";
|
|
6
|
+
import type { HttpAgent } from "@ag-ui/client";
|
|
7
|
+
import { useAgUiRuntime } from "./useAgUiRuntime";
|
|
8
|
+
|
|
9
|
+
const agent = { runAgent: vi.fn(), abortRun: vi.fn() } as unknown as HttpAgent;
|
|
10
|
+
|
|
11
|
+
function createHistory(): ThreadHistoryAdapter {
|
|
12
|
+
return {
|
|
13
|
+
load: vi.fn().mockResolvedValue({
|
|
14
|
+
headId: "restored",
|
|
15
|
+
messages: [
|
|
16
|
+
{
|
|
17
|
+
parentId: null,
|
|
18
|
+
message: {
|
|
19
|
+
id: "restored",
|
|
20
|
+
role: "user" as const,
|
|
21
|
+
content: [{ type: "text" as const, text: "hello" }],
|
|
22
|
+
createdAt: new Date(0),
|
|
23
|
+
metadata: { custom: {} },
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
}),
|
|
28
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("useAgUiRuntime history", () => {
|
|
37
|
+
it("loads a history adapter that arrives on a later render", async () => {
|
|
38
|
+
const history = createHistory();
|
|
39
|
+
const { result, rerender } = renderHook(
|
|
40
|
+
({ history }: { history?: ThreadHistoryAdapter }) =>
|
|
41
|
+
useAgUiRuntime({ agent, adapters: history ? { history } : {} }),
|
|
42
|
+
{ initialProps: {} },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
await waitFor(() =>
|
|
46
|
+
expect(result.current.thread.getState().isLoading).toBe(false),
|
|
47
|
+
);
|
|
48
|
+
expect(history.load).not.toHaveBeenCalled();
|
|
49
|
+
expect(result.current.thread.getState().messages).toEqual([]);
|
|
50
|
+
|
|
51
|
+
rerender({ history });
|
|
52
|
+
|
|
53
|
+
await waitFor(() =>
|
|
54
|
+
expect(
|
|
55
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
56
|
+
).toEqual(["restored"]),
|
|
57
|
+
);
|
|
58
|
+
expect(history.load).toHaveBeenCalledOnce();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("loads once when the adapter is present from the first render", async () => {
|
|
62
|
+
const history = createHistory();
|
|
63
|
+
const { result, rerender } = renderHook(() =>
|
|
64
|
+
useAgUiRuntime({ agent, adapters: { history } }),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await waitFor(() =>
|
|
68
|
+
expect(
|
|
69
|
+
result.current.thread.getState().messages.map((m) => m.id),
|
|
70
|
+
).toEqual(["restored"]),
|
|
71
|
+
);
|
|
72
|
+
rerender();
|
|
73
|
+
rerender();
|
|
74
|
+
expect(history.load).toHaveBeenCalledOnce();
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/useAgUiRuntime.ts
CHANGED
|
@@ -81,14 +81,16 @@ export function useAgUiRuntime(
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const core = coreRef.current;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
core.updateOptions({
|
|
86
|
+
agent: options.agent,
|
|
87
|
+
logger,
|
|
88
|
+
showThinking: options.showThinking ?? true,
|
|
89
|
+
autoCancelPendingToolCalls: options.autoCancelPendingToolCalls,
|
|
90
|
+
...(options.onError && { onError: options.onError }),
|
|
91
|
+
...(options.onCancel && { onCancel: options.onCancel }),
|
|
92
|
+
...(historyAdapter && { history: historyAdapter }),
|
|
93
|
+
});
|
|
92
94
|
});
|
|
93
95
|
|
|
94
96
|
const [toolStatuses, setToolStatuses] = useState<
|
|
@@ -228,7 +230,7 @@ export function useAgUiRuntime(
|
|
|
228
230
|
isLoading: core.isLoading,
|
|
229
231
|
messageRepository: core.getMessageRepository(),
|
|
230
232
|
state: core.getState(),
|
|
231
|
-
isRunning,
|
|
233
|
+
isRunning: core.isRunning(),
|
|
232
234
|
extras: agUiExtras.provide({
|
|
233
235
|
interrupts:
|
|
234
236
|
core.getPendingInterrupts()?.interrupts ?? EMPTY_INTERRUPTS,
|