@assistant-ui/react-ag-ui 0.0.58 → 0.0.59
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/index.d.ts +2 -2
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts +16 -4
- package/dist/runtime/AgUiThreadRuntimeCore.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadRuntimeCore.js +90 -44
- package/dist/runtime/AgUiThreadRuntimeCore.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +59 -0
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/dist/runtime/types.d.ts +26 -1
- package/dist/runtime/types.d.ts.map +1 -1
- package/dist/useAgUiRuntime.d.ts.map +1 -1
- package/dist/useAgUiRuntime.js +14 -5
- package/dist/useAgUiRuntime.js.map +1 -1
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/runtime/AgUiThreadRuntimeCore.test.ts +18 -0
- package/src/runtime/AgUiThreadRuntimeCore.ts +166 -62
- package/src/runtime/adapter/run-aggregator.ts +85 -0
- package/src/runtime/types.ts +26 -0
- package/src/useAgUiRuntime.queue.test.tsx +54 -0
- package/src/useAgUiRuntime.thread-switch.test.tsx +199 -0
- package/src/useAgUiRuntime.ts +14 -3
|
@@ -35,7 +35,12 @@ import type {
|
|
|
35
35
|
import jsonpatch, { type Operation } from "fast-json-patch";
|
|
36
36
|
import type { Logger } from "./logger";
|
|
37
37
|
import { readMcpAppResourceUri } from "./mcp-tool-result";
|
|
38
|
-
import type {
|
|
38
|
+
import type {
|
|
39
|
+
AgUiEvent,
|
|
40
|
+
AgUiInterrupt,
|
|
41
|
+
AgUiResumeEntry,
|
|
42
|
+
AgUiResumeTranscript,
|
|
43
|
+
} from "./types";
|
|
39
44
|
import type { ReadonlyJSONValue } from "assistant-stream/utils";
|
|
40
45
|
import {
|
|
41
46
|
AG_UI_METADATA_NAMESPACE,
|
|
@@ -87,10 +92,21 @@ type ResumeRunConfig = {
|
|
|
87
92
|
stream?: ResumeStream;
|
|
88
93
|
};
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* A resume submission, pairing the entries that answer the open interrupts with
|
|
97
|
+
* the assistant message they were opened on. That message anchors the resume
|
|
98
|
+
* transcript: under `resumeTranscript: "appended"` only what follows it is sent.
|
|
99
|
+
*/
|
|
100
|
+
type ResumeDispatch = {
|
|
101
|
+
entries: AgUiResumeEntry[];
|
|
102
|
+
interruptedMessageId: string;
|
|
103
|
+
};
|
|
104
|
+
|
|
90
105
|
type CoreOptions = {
|
|
91
106
|
agent: AbstractAgent;
|
|
92
107
|
logger: Logger;
|
|
93
108
|
showThinking: boolean;
|
|
109
|
+
resumeTranscript?: AgUiResumeTranscript | undefined;
|
|
94
110
|
autoCancelPendingToolCalls?: boolean | undefined;
|
|
95
111
|
onError?: (error: Error) => void;
|
|
96
112
|
onCancel?: () => void;
|
|
@@ -135,6 +151,7 @@ export class AgUiThreadRuntimeCore {
|
|
|
135
151
|
private agent: AbstractAgent;
|
|
136
152
|
private logger: Logger;
|
|
137
153
|
private showThinking: boolean;
|
|
154
|
+
private resumeTranscript: AgUiResumeTranscript;
|
|
138
155
|
private autoCancelPendingToolCalls: boolean | undefined;
|
|
139
156
|
private onError: ((error: Error) => void) | undefined;
|
|
140
157
|
private onCancel: (() => void) | undefined;
|
|
@@ -164,7 +181,6 @@ export class AgUiThreadRuntimeCore {
|
|
|
164
181
|
// mid-run, and cancelling has to reach the agent holding the live request.
|
|
165
182
|
private activeRunAgent: AbstractAgent | null = null;
|
|
166
183
|
private stateSnapshot: ReadonlyJSONValue | undefined;
|
|
167
|
-
private pendingError: Error | null = null;
|
|
168
184
|
private history: ThreadHistoryAdapter | undefined;
|
|
169
185
|
private lastRunConfig: RunConfig | undefined;
|
|
170
186
|
private readonly assistantHistoryParents = new Map<string, string | null>();
|
|
@@ -174,14 +190,16 @@ export class AgUiThreadRuntimeCore {
|
|
|
174
190
|
private _isLoading = false;
|
|
175
191
|
private _loadPromise: Promise<void> | undefined;
|
|
176
192
|
private _loadRequested = false;
|
|
177
|
-
private
|
|
178
|
-
|
|
193
|
+
private pendingResume: { owner: AbortController; messageId: string } | null =
|
|
194
|
+
null;
|
|
195
|
+
private pendingA2uiResumeOwner: AbortController | null = null;
|
|
179
196
|
private pendingA2uiAction: Record<string, unknown> | undefined;
|
|
180
197
|
|
|
181
198
|
constructor(options: CoreOptions) {
|
|
182
199
|
this.agent = options.agent;
|
|
183
200
|
this.logger = options.logger;
|
|
184
201
|
this.showThinking = options.showThinking;
|
|
202
|
+
this.resumeTranscript = options.resumeTranscript ?? "full";
|
|
185
203
|
this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
|
|
186
204
|
this.onError = options.onError;
|
|
187
205
|
this.onCancel = options.onCancel;
|
|
@@ -193,6 +211,7 @@ export class AgUiThreadRuntimeCore {
|
|
|
193
211
|
this.agent = options.agent;
|
|
194
212
|
this.logger = options.logger;
|
|
195
213
|
this.showThinking = options.showThinking;
|
|
214
|
+
this.resumeTranscript = options.resumeTranscript ?? "full";
|
|
196
215
|
this.autoCancelPendingToolCalls = options.autoCancelPendingToolCalls;
|
|
197
216
|
this.onError = options.onError;
|
|
198
217
|
this.onCancel = options.onCancel;
|
|
@@ -296,12 +315,17 @@ export class AgUiThreadRuntimeCore {
|
|
|
296
315
|
|
|
297
316
|
async append(message: AppendMessage): Promise<void> {
|
|
298
317
|
const startRun = message.startRun ?? message.role === "user";
|
|
318
|
+
let ownsThread = true;
|
|
299
319
|
if (startRun) {
|
|
300
320
|
this.assertNoPendingInterrupts();
|
|
301
321
|
this.maybeAutoCancelPendingToolCalls();
|
|
322
|
+
// Before the message is linked: callers parent it on the in-flight
|
|
323
|
+
// assistant, and settling that assistant reassigns its optimistic id,
|
|
324
|
+
// which would evict it and reparent this message onto a branch.
|
|
325
|
+
ownsThread = this.supersedeActiveRun();
|
|
302
326
|
}
|
|
303
327
|
const threadMessageId = this.appendEntry(message);
|
|
304
|
-
if (!startRun) return;
|
|
328
|
+
if (!startRun || !ownsThread) return;
|
|
305
329
|
await this.startRun(threadMessageId, message.runConfig);
|
|
306
330
|
}
|
|
307
331
|
|
|
@@ -344,19 +368,46 @@ export class AgUiThreadRuntimeCore {
|
|
|
344
368
|
}
|
|
345
369
|
|
|
346
370
|
async cancel(): Promise<void> {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
371
|
+
this.abortActiveRun();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// abortRun is what stops an upstream agent: AbstractAgent.runAgent declares
|
|
375
|
+
// two parameters and HttpAgent binds its request to a controller of its own,
|
|
376
|
+
// so the run options object reaches only subclasses that read it. The local
|
|
377
|
+
// abort comes last and is unconditional: its listener runs onCancel
|
|
378
|
+
// synchronously, so a callback that starts another run must keep the
|
|
379
|
+
// controller it installed, and a throw from a subclass's abortRun would
|
|
380
|
+
// otherwise strand the thread as running.
|
|
381
|
+
private abortActiveRun(): void {
|
|
382
|
+
const controller = this.abortController;
|
|
383
|
+
if (!controller) return;
|
|
353
384
|
try {
|
|
354
385
|
(this.activeRunAgent ?? this.agent).abortRun();
|
|
355
386
|
} finally {
|
|
356
|
-
|
|
387
|
+
controller.abort();
|
|
357
388
|
}
|
|
358
389
|
}
|
|
359
390
|
|
|
391
|
+
// Starting a run supersedes the active one, and reports whether the caller
|
|
392
|
+
// still owns the thread afterwards: the local abort runs onCancel
|
|
393
|
+
// synchronously, so a callback that starts its own run keeps it. abortRun is a
|
|
394
|
+
// subclass's code, and a throw there must not abandon the run being started,
|
|
395
|
+
// unlike cancel(), where the failed operation is the caller's own. Calling
|
|
396
|
+
// this twice for one run is harmless, because the second call finds no active
|
|
397
|
+
// run to supersede.
|
|
398
|
+
private supersedeActiveRun(): boolean {
|
|
399
|
+
try {
|
|
400
|
+
this.abortActiveRun();
|
|
401
|
+
} catch (error) {
|
|
402
|
+
this.logger.error?.("[agui] agent abortRun failed", error);
|
|
403
|
+
}
|
|
404
|
+
if (this.abortController === null) return true;
|
|
405
|
+
this.logger.debug?.(
|
|
406
|
+
"[agui] onCancel started a replacement run; dropping the superseding send",
|
|
407
|
+
);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
|
|
360
411
|
async resume(config: ResumeRunConfig): Promise<void> {
|
|
361
412
|
this.assertNoPendingInterrupts();
|
|
362
413
|
await this.startRun(
|
|
@@ -490,7 +541,10 @@ export class AgUiThreadRuntimeCore {
|
|
|
490
541
|
resume: AgUiResumeEntry[],
|
|
491
542
|
): Promise<void> {
|
|
492
543
|
this.clearPendingInterrupts(messageId, resume);
|
|
493
|
-
await this.startRun(messageId, this.lastRunConfig,
|
|
544
|
+
await this.startRun(messageId, this.lastRunConfig, {
|
|
545
|
+
entries: resume,
|
|
546
|
+
interruptedMessageId: messageId,
|
|
547
|
+
});
|
|
494
548
|
}
|
|
495
549
|
|
|
496
550
|
/**
|
|
@@ -649,7 +703,10 @@ export class AgUiThreadRuntimeCore {
|
|
|
649
703
|
const normalized = this.toAppendMessage(message);
|
|
650
704
|
this.clearPendingInterrupts(pending.messageId, resume);
|
|
651
705
|
const threadMessageId = this.appendEntry(normalized);
|
|
652
|
-
await this.startRun(threadMessageId, normalized.runConfig,
|
|
706
|
+
await this.startRun(threadMessageId, normalized.runConfig, {
|
|
707
|
+
entries: resume,
|
|
708
|
+
interruptedMessageId: pending.messageId,
|
|
709
|
+
});
|
|
653
710
|
}
|
|
654
711
|
|
|
655
712
|
private resolveSteerAwayResume(
|
|
@@ -718,7 +775,8 @@ export class AgUiThreadRuntimeCore {
|
|
|
718
775
|
}
|
|
719
776
|
return {
|
|
720
777
|
createdAt: message.createdAt ?? new Date(),
|
|
721
|
-
parentId:
|
|
778
|
+
parentId:
|
|
779
|
+
message.parentId === undefined ? this.session.headId : message.parentId,
|
|
722
780
|
sourceId: message.sourceId ?? null,
|
|
723
781
|
role: message.role ?? "user",
|
|
724
782
|
content: message.content,
|
|
@@ -847,8 +905,9 @@ export class AgUiThreadRuntimeCore {
|
|
|
847
905
|
}
|
|
848
906
|
this.pendingA2uiAction = userAction;
|
|
849
907
|
|
|
850
|
-
|
|
851
|
-
|
|
908
|
+
const owner = this.abortController;
|
|
909
|
+
if (owner) {
|
|
910
|
+
this.pendingA2uiResumeOwner = owner;
|
|
852
911
|
return;
|
|
853
912
|
}
|
|
854
913
|
this.startResumeRun(parentId);
|
|
@@ -860,10 +919,11 @@ export class AgUiThreadRuntimeCore {
|
|
|
860
919
|
private maybeResumeAfterToolResults(messageId: string): void {
|
|
861
920
|
if (!this.maybeCompleteAfterToolResults(messageId)) return;
|
|
862
921
|
|
|
863
|
-
|
|
922
|
+
const owner = this.abortController;
|
|
923
|
+
if (owner) {
|
|
864
924
|
// A run is still draining (RUN_FINISHED arrived but the stream has not
|
|
865
925
|
// closed). Defer until startRun's tail so we never start two runs.
|
|
866
|
-
this.
|
|
926
|
+
this.pendingResume = { owner, messageId };
|
|
867
927
|
return;
|
|
868
928
|
}
|
|
869
929
|
this.startResumeRun(messageId);
|
|
@@ -913,7 +973,7 @@ export class AgUiThreadRuntimeCore {
|
|
|
913
973
|
}
|
|
914
974
|
|
|
915
975
|
applyExternalMessages(messages: readonly ThreadMessage[]): void {
|
|
916
|
-
this.
|
|
976
|
+
this.pendingA2uiResumeOwner = null;
|
|
917
977
|
this.pendingA2uiAction = undefined;
|
|
918
978
|
this.assistantHistoryParents.clear();
|
|
919
979
|
|
|
@@ -959,6 +1019,17 @@ export class AgUiThreadRuntimeCore {
|
|
|
959
1019
|
for (const { message } of this.getMessageRepository().messages) {
|
|
960
1020
|
this.snapshotHistoryIds.add(message.id);
|
|
961
1021
|
}
|
|
1022
|
+
// MESSAGES_SNAPSHOT re-appends the active assistant, so a parked
|
|
1023
|
+
// continuation whose target survived the snapshot is still answerable.
|
|
1024
|
+
// Membership is the head branch, not the repository: a soft merge leaves
|
|
1025
|
+
// the messages it dropped behind as off-branch nodes.
|
|
1026
|
+
const resumeTarget = this.pendingResume?.messageId;
|
|
1027
|
+
if (
|
|
1028
|
+
resumeTarget !== undefined &&
|
|
1029
|
+
!this.session.getMessages().some((message) => message.id === resumeTarget)
|
|
1030
|
+
) {
|
|
1031
|
+
this.pendingResume = null;
|
|
1032
|
+
}
|
|
962
1033
|
this.notifyUpdate();
|
|
963
1034
|
}
|
|
964
1035
|
|
|
@@ -985,9 +1056,14 @@ export class AgUiThreadRuntimeCore {
|
|
|
985
1056
|
private async startRun(
|
|
986
1057
|
parentId: string | null,
|
|
987
1058
|
runConfig?: RunConfig,
|
|
988
|
-
resume?:
|
|
1059
|
+
resume?: ResumeDispatch,
|
|
989
1060
|
resumeStream?: ResumeStream,
|
|
990
1061
|
): Promise<void> {
|
|
1062
|
+
// A default AG-UI run supersedes the active run; the hook's opt-in message
|
|
1063
|
+
// queue serializes sends instead. append supersedes earlier, before it links
|
|
1064
|
+
// its message; this covers the entry points that start a run without one.
|
|
1065
|
+
if (!this.supersedeActiveRun()) return;
|
|
1066
|
+
|
|
991
1067
|
const normalizedRunConfig = runConfig ?? {};
|
|
992
1068
|
this.lastRunConfig = normalizedRunConfig;
|
|
993
1069
|
const parent =
|
|
@@ -1005,7 +1081,7 @@ export class AgUiThreadRuntimeCore {
|
|
|
1005
1081
|
this.getMessageRepository().messages.map(({ message }) => message.id),
|
|
1006
1082
|
);
|
|
1007
1083
|
|
|
1008
|
-
|
|
1084
|
+
let pendingError: Error | null = null;
|
|
1009
1085
|
const assistantParentId = parent ? parentId : this.session.headId;
|
|
1010
1086
|
let assistantMessageId: string | undefined;
|
|
1011
1087
|
// A snapshot the preserve gate declines still evicts the in-flight
|
|
@@ -1131,16 +1207,17 @@ export class AgUiThreadRuntimeCore {
|
|
|
1131
1207
|
// Cancel flips only the status; an aggregator RUN_CANCELLED would emit an empty snapshot and wipe the replayed content.
|
|
1132
1208
|
cancelRun = () =>
|
|
1133
1209
|
applyUpdate({ status: { type: "incomplete", reason: "cancelled" } });
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1210
|
+
pendingError =
|
|
1211
|
+
(await this.consumeResumeStream(resumeStream, {
|
|
1212
|
+
runConfig: normalizedRunConfig,
|
|
1213
|
+
threadId: this.agent.threadId || "main",
|
|
1214
|
+
parentId: assistantParentId,
|
|
1215
|
+
historicalMessages,
|
|
1216
|
+
abortSignal,
|
|
1217
|
+
ensureAssistant,
|
|
1218
|
+
applyUpdate,
|
|
1219
|
+
getAssistantMessageId: () => assistantMessageId,
|
|
1220
|
+
})) ?? null;
|
|
1144
1221
|
} else {
|
|
1145
1222
|
const runId = generateId();
|
|
1146
1223
|
aggregator.handle({ type: "RUN_STARTED", runId });
|
|
@@ -1156,7 +1233,7 @@ export class AgUiThreadRuntimeCore {
|
|
|
1156
1233
|
logger: this.logger,
|
|
1157
1234
|
onRunFailed: (error) => {
|
|
1158
1235
|
if (abortSignal.aborted) return;
|
|
1159
|
-
|
|
1236
|
+
pendingError = error;
|
|
1160
1237
|
invokeRuntimeCallback("onError", this.onError, error);
|
|
1161
1238
|
},
|
|
1162
1239
|
});
|
|
@@ -1176,36 +1253,31 @@ export class AgUiThreadRuntimeCore {
|
|
|
1176
1253
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1177
1254
|
dispatch({ type: "RUN_ERROR", message: err.message });
|
|
1178
1255
|
invokeRuntimeCallback("onError", this.onError, err);
|
|
1179
|
-
|
|
1256
|
+
pendingError ??= err;
|
|
1180
1257
|
}
|
|
1181
1258
|
} finally {
|
|
1182
1259
|
this.finishRun(abortController);
|
|
1183
1260
|
}
|
|
1184
1261
|
|
|
1185
|
-
if (
|
|
1186
|
-
const err =
|
|
1262
|
+
if (pendingError) {
|
|
1263
|
+
const err = pendingError;
|
|
1187
1264
|
this.reportedErrors.add(err);
|
|
1188
|
-
this.
|
|
1189
|
-
this.pendingResumeMessageId = null;
|
|
1190
|
-
this.pendingA2uiResume = false;
|
|
1191
|
-
this.pendingA2uiAction = undefined;
|
|
1265
|
+
this.clearDeferredContinuations(abortController);
|
|
1192
1266
|
throw err;
|
|
1193
1267
|
}
|
|
1194
1268
|
|
|
1195
1269
|
// A tool result that landed before the run settled deferred its
|
|
1196
1270
|
// continuation here so a second run never overlaps the first.
|
|
1197
|
-
if (this.
|
|
1198
|
-
const
|
|
1199
|
-
this.
|
|
1271
|
+
if (this.pendingResume?.owner === abortController) {
|
|
1272
|
+
const { messageId } = this.pendingResume;
|
|
1273
|
+
this.pendingResume = null;
|
|
1200
1274
|
if (!abortSignal.aborted) {
|
|
1201
|
-
this.startResumeRun(
|
|
1202
|
-
} else {
|
|
1203
|
-
this.pendingA2uiAction = undefined;
|
|
1275
|
+
this.startResumeRun(messageId);
|
|
1204
1276
|
}
|
|
1205
1277
|
}
|
|
1206
1278
|
|
|
1207
|
-
if (this.
|
|
1208
|
-
this.
|
|
1279
|
+
if (this.pendingA2uiResumeOwner === abortController) {
|
|
1280
|
+
this.pendingA2uiResumeOwner = null;
|
|
1209
1281
|
if (!abortSignal.aborted && this.pendingA2uiAction !== undefined) {
|
|
1210
1282
|
if (this.getPendingInterrupts()) {
|
|
1211
1283
|
this.pendingA2uiAction = undefined;
|
|
@@ -1243,9 +1315,9 @@ export class AgUiThreadRuntimeCore {
|
|
|
1243
1315
|
applyUpdate: (update: ChatModelRunResult) => void;
|
|
1244
1316
|
getAssistantMessageId: () => string | undefined;
|
|
1245
1317
|
},
|
|
1246
|
-
): Promise<
|
|
1318
|
+
): Promise<Error | undefined> {
|
|
1247
1319
|
this.pendingA2uiAction = undefined;
|
|
1248
|
-
this.
|
|
1320
|
+
this.pendingA2uiResumeOwner = null;
|
|
1249
1321
|
const assistantId = ctx.ensureAssistant();
|
|
1250
1322
|
const currentId = () => ctx.getAssistantMessageId() ?? assistantId;
|
|
1251
1323
|
const options: ChatModelRunOptions = {
|
|
@@ -1269,36 +1341,39 @@ export class AgUiThreadRuntimeCore {
|
|
|
1269
1341
|
|
|
1270
1342
|
try {
|
|
1271
1343
|
for await (const result of stream(options)) {
|
|
1272
|
-
if (ctx.abortSignal.aborted) return;
|
|
1344
|
+
if (ctx.abortSignal.aborted) return undefined;
|
|
1273
1345
|
ctx.applyUpdate(result);
|
|
1274
1346
|
}
|
|
1275
1347
|
} catch (error) {
|
|
1276
|
-
if (ctx.abortSignal.aborted) return;
|
|
1348
|
+
if (ctx.abortSignal.aborted) return undefined;
|
|
1277
1349
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
1278
1350
|
ctx.applyUpdate({
|
|
1279
1351
|
status: { type: "incomplete", reason: "error", error: err.message },
|
|
1280
1352
|
});
|
|
1281
1353
|
invokeRuntimeCallback("onError", this.onError, err);
|
|
1282
|
-
|
|
1283
|
-
return;
|
|
1354
|
+
return err;
|
|
1284
1355
|
}
|
|
1285
1356
|
|
|
1286
|
-
if (ctx.abortSignal.aborted) return;
|
|
1357
|
+
if (ctx.abortSignal.aborted) return undefined;
|
|
1287
1358
|
const current = this.session.tryGetMessage(currentId())?.message;
|
|
1288
1359
|
if (!current || current.status?.type === "running") {
|
|
1289
1360
|
ctx.applyUpdate({ status: { type: "complete", reason: "unknown" } });
|
|
1290
1361
|
}
|
|
1362
|
+
return undefined;
|
|
1291
1363
|
}
|
|
1292
1364
|
|
|
1293
1365
|
private buildRunInput(
|
|
1294
1366
|
runId: string,
|
|
1295
1367
|
runConfig: RunConfig | undefined,
|
|
1296
1368
|
historyMessages: readonly ThreadMessage[] | undefined,
|
|
1297
|
-
resume?:
|
|
1369
|
+
resume?: ResumeDispatch,
|
|
1298
1370
|
) {
|
|
1299
1371
|
const threadId = this.agent.threadId || "main";
|
|
1300
1372
|
const messages = toAgUiMessages(
|
|
1301
|
-
|
|
1373
|
+
this.selectRunMessages(
|
|
1374
|
+
historyMessages ?? this.session.getMessages(),
|
|
1375
|
+
resume,
|
|
1376
|
+
),
|
|
1302
1377
|
);
|
|
1303
1378
|
const context = this.runtime?.thread.getModelContext();
|
|
1304
1379
|
const input = {
|
|
@@ -1318,22 +1393,51 @@ export class AgUiThreadRuntimeCore {
|
|
|
1318
1393
|
? { a2uiAction: { userAction: this.pendingA2uiAction } }
|
|
1319
1394
|
: {}),
|
|
1320
1395
|
},
|
|
1321
|
-
...(resume !== undefined ? { resume } : {}),
|
|
1396
|
+
...(resume !== undefined ? { resume: resume.entries } : {}),
|
|
1322
1397
|
};
|
|
1323
1398
|
this.pendingA2uiAction = undefined;
|
|
1324
1399
|
return input;
|
|
1325
1400
|
}
|
|
1326
1401
|
|
|
1402
|
+
/**
|
|
1403
|
+
* An anchor the thread no longer carries falls back to the whole transcript.
|
|
1404
|
+
* The anchor is missing exactly when a snapshot import replaced the messages
|
|
1405
|
+
* this resume was raised against, and a host that expects a delta tolerates
|
|
1406
|
+
* being told more than it knows, while one rebuilding the run from the
|
|
1407
|
+
* transcript cannot recover from being told less.
|
|
1408
|
+
*/
|
|
1409
|
+
private selectRunMessages(
|
|
1410
|
+
messages: readonly ThreadMessage[],
|
|
1411
|
+
resume: ResumeDispatch | undefined,
|
|
1412
|
+
): readonly ThreadMessage[] {
|
|
1413
|
+
if (resume === undefined || this.resumeTranscript !== "appended")
|
|
1414
|
+
return messages;
|
|
1415
|
+
const anchor = messages.findIndex(
|
|
1416
|
+
(message) => message.id === resume.interruptedMessageId,
|
|
1417
|
+
);
|
|
1418
|
+
if (anchor === -1) return messages;
|
|
1419
|
+
return messages.slice(anchor + 1);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private clearDeferredContinuations(owner: AbortController): void {
|
|
1423
|
+
if (this.pendingResume?.owner === owner) {
|
|
1424
|
+
this.pendingResume = null;
|
|
1425
|
+
}
|
|
1426
|
+
if (this.pendingA2uiResumeOwner === owner) {
|
|
1427
|
+
this.pendingA2uiResumeOwner = null;
|
|
1428
|
+
this.pendingA2uiAction = undefined;
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1327
1432
|
private setRunning(running: boolean) {
|
|
1328
1433
|
this.isRunningFlag = running;
|
|
1329
1434
|
this.notifyUpdate();
|
|
1330
1435
|
}
|
|
1331
1436
|
|
|
1332
1437
|
private finishRun(controller: AbortController | null) {
|
|
1333
|
-
if (this.abortController
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
}
|
|
1438
|
+
if (this.abortController !== controller) return;
|
|
1439
|
+
this.abortController = null;
|
|
1440
|
+
this.activeRunAgent = null;
|
|
1337
1441
|
this.setRunning(false);
|
|
1338
1442
|
}
|
|
1339
1443
|
|
|
@@ -87,6 +87,7 @@ type ToolCallState = {
|
|
|
87
87
|
toolCallId: string;
|
|
88
88
|
toolCallName: string;
|
|
89
89
|
argsText: string;
|
|
90
|
+
argsTextScanner?: JSONContainerScanner | undefined;
|
|
90
91
|
parsedArgs: Record<string, unknown> | undefined;
|
|
91
92
|
result: unknown;
|
|
92
93
|
isError: boolean | undefined;
|
|
@@ -99,6 +100,80 @@ type ToolCallState = {
|
|
|
99
100
|
subagentRunId?: string;
|
|
100
101
|
};
|
|
101
102
|
|
|
103
|
+
type JSONContainerScanner = {
|
|
104
|
+
state: "pending" | "open" | "complete" | "invalid";
|
|
105
|
+
stack: ("{" | "[")[];
|
|
106
|
+
inString: boolean;
|
|
107
|
+
escaped: boolean;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const createJSONContainerScanner = (): JSONContainerScanner => ({
|
|
111
|
+
state: "pending",
|
|
112
|
+
stack: [],
|
|
113
|
+
inString: false,
|
|
114
|
+
escaped: false,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const isJSONWhitespace = (char: string): boolean =>
|
|
118
|
+
char === " " || char === "\t" || char === "\n" || char === "\r";
|
|
119
|
+
|
|
120
|
+
const scanJSONContainerDelta = (
|
|
121
|
+
scanner: JSONContainerScanner,
|
|
122
|
+
delta: string,
|
|
123
|
+
): boolean => {
|
|
124
|
+
let completed = false;
|
|
125
|
+
|
|
126
|
+
for (const char of delta) {
|
|
127
|
+
if (scanner.state === "invalid") continue;
|
|
128
|
+
if (scanner.state === "complete") {
|
|
129
|
+
if (isJSONWhitespace(char)) continue;
|
|
130
|
+
scanner.state = "invalid";
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (scanner.state === "pending") {
|
|
134
|
+
if (isJSONWhitespace(char)) continue;
|
|
135
|
+
if (char !== "{" && char !== "[") {
|
|
136
|
+
scanner.state = "invalid";
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
scanner.state = "open";
|
|
140
|
+
scanner.stack.push(char);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (scanner.inString) {
|
|
144
|
+
if (scanner.escaped) {
|
|
145
|
+
scanner.escaped = false;
|
|
146
|
+
} else if (char === "\\") {
|
|
147
|
+
scanner.escaped = true;
|
|
148
|
+
} else if (char === '"') {
|
|
149
|
+
scanner.inString = false;
|
|
150
|
+
}
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (char === '"') {
|
|
154
|
+
scanner.inString = true;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (char === "{" || char === "[") {
|
|
158
|
+
scanner.stack.push(char);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (char === "}" || char === "]") {
|
|
162
|
+
const expected = char === "}" ? "{" : "[";
|
|
163
|
+
if (scanner.stack.pop() !== expected) {
|
|
164
|
+
scanner.state = "invalid";
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (scanner.stack.length === 0) {
|
|
168
|
+
scanner.state = "complete";
|
|
169
|
+
completed = true;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return completed && scanner.state === "complete";
|
|
175
|
+
};
|
|
176
|
+
|
|
102
177
|
export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
|
|
103
178
|
|
|
104
179
|
export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
|
|
@@ -816,7 +891,17 @@ export class RunAggregator {
|
|
|
816
891
|
private appendToolArgs(id: string | undefined, delta: string) {
|
|
817
892
|
const entry = id ? this.toolCalls.get(id) : undefined;
|
|
818
893
|
if (!entry) return;
|
|
894
|
+
if (!entry.argsTextScanner) {
|
|
895
|
+
entry.argsTextScanner = createJSONContainerScanner();
|
|
896
|
+
}
|
|
819
897
|
entry.argsText += delta;
|
|
898
|
+
const shouldParse = scanJSONContainerDelta(entry.argsTextScanner, delta);
|
|
899
|
+
if (!shouldParse) {
|
|
900
|
+
if (entry.argsTextScanner.state === "invalid") {
|
|
901
|
+
entry.parsedArgs = undefined;
|
|
902
|
+
}
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
820
905
|
try {
|
|
821
906
|
const parsed = JSON.parse(entry.argsText);
|
|
822
907
|
if (parsed && typeof parsed === "object") {
|
package/src/runtime/types.ts
CHANGED
|
@@ -56,10 +56,36 @@ export type UseAgUiRuntimeAdapters = {
|
|
|
56
56
|
threadList?: UseAgUiThreadListAdapter;
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
export type AgUiResumeTranscript = "full" | "appended";
|
|
60
|
+
|
|
59
61
|
export type UseAgUiRuntimeOptions = ExternalStoreSharedOptions & {
|
|
60
62
|
agent: AbstractAgent;
|
|
61
63
|
logger?: Partial<Logger>;
|
|
62
64
|
showThinking?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* What `messages` carries on a resume run, meaning a `RunAgentInput` that
|
|
67
|
+
* also carries `resume`. The AG-UI interrupt spec leaves this undefined: its
|
|
68
|
+
* contract rules constrain the thread id, interrupt coverage, idempotency,
|
|
69
|
+
* and expiry, and none of them mentions `messages`. Hosts therefore disagree,
|
|
70
|
+
* and both readings are conformant.
|
|
71
|
+
*
|
|
72
|
+
* `"full"` sends the whole thread, which is what `@ag-ui/client` itself does.
|
|
73
|
+
* Hosts that resume from a checkpoint ignore the transcript, and hosts that
|
|
74
|
+
* rebuild the interrupted run from it need it.
|
|
75
|
+
*
|
|
76
|
+
* `"appended"` sends only what was appended locally after the interrupted
|
|
77
|
+
* assistant message, which is nothing for an approval or a denial and the new
|
|
78
|
+
* user turn for `steerAway`. Choose it for a host that owns the thread and
|
|
79
|
+
* seeds a resume request from its own stored snapshot, because such a host
|
|
80
|
+
* appends the request body to that snapshot and a re-sent transcript
|
|
81
|
+
* duplicates the interrupted turn in its stored history. A host that rebuilds
|
|
82
|
+
* the run from `messages` has nothing to resume from under `"appended"`, and
|
|
83
|
+
* a host that derives its outgoing `MESSAGES_SNAPSHOT` from the request body
|
|
84
|
+
* emits a truncated one.
|
|
85
|
+
*
|
|
86
|
+
* Defaults to `"full"`.
|
|
87
|
+
*/
|
|
88
|
+
resumeTranscript?: AgUiResumeTranscript | undefined;
|
|
63
89
|
/**
|
|
64
90
|
* When the user sends, edits, or reloads a message while client-side tool
|
|
65
91
|
* calls are still pending, automatically cancel the unresolved tool calls
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
|
|
3
3
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
4
|
+
import { startTransition, useEffect } from "react";
|
|
4
5
|
import {
|
|
5
6
|
act,
|
|
6
7
|
cleanup,
|
|
@@ -409,6 +410,59 @@ describe("useAgUiRuntime unstable_enableMessageQueue", () => {
|
|
|
409
410
|
);
|
|
410
411
|
});
|
|
411
412
|
|
|
413
|
+
it("does not clear the committed queue from a suspended render", async () => {
|
|
414
|
+
const { agent, runAgent } = gatedAgent();
|
|
415
|
+
let runtime: AssistantRuntime | undefined;
|
|
416
|
+
const never = new Promise<void>(() => {});
|
|
417
|
+
|
|
418
|
+
const Harness = ({
|
|
419
|
+
enabled,
|
|
420
|
+
suspend,
|
|
421
|
+
}: {
|
|
422
|
+
enabled: boolean;
|
|
423
|
+
suspend: boolean;
|
|
424
|
+
}) => {
|
|
425
|
+
const nextRuntime = useAgUiRuntime({
|
|
426
|
+
agent,
|
|
427
|
+
unstable_enableMessageQueue: enabled,
|
|
428
|
+
});
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
runtime = nextRuntime;
|
|
431
|
+
}, [nextRuntime]);
|
|
432
|
+
if (suspend) throw never;
|
|
433
|
+
return null;
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
const view = render(<Harness enabled suspend={false} />);
|
|
437
|
+
await waitFor(() => expect(runtime).toBeDefined());
|
|
438
|
+
mount(runtime!);
|
|
439
|
+
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await runtime!.thread.append({
|
|
442
|
+
role: "user",
|
|
443
|
+
content: [{ type: "text", text: "first" }],
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
await waitFor(() => expect(runAgent).toHaveBeenCalledTimes(1));
|
|
447
|
+
|
|
448
|
+
await act(async () => {
|
|
449
|
+
await runtime!.thread.append({
|
|
450
|
+
role: "user",
|
|
451
|
+
content: [{ type: "text", text: "second" }],
|
|
452
|
+
parentId: runtime!.thread.getState().messages.at(-1)?.id ?? null,
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
await waitFor(() =>
|
|
456
|
+
expect(screen.getByTestId("queued").textContent).toBe("second"),
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
act(() => {
|
|
460
|
+
startTransition(() => view.rerender(<Harness enabled={false} suspend />));
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(screen.getByTestId("queued").textContent).toBe("second");
|
|
464
|
+
});
|
|
465
|
+
|
|
412
466
|
it("leaves the queue capability off when the flag is not set", () => {
|
|
413
467
|
const { agent } = gatedAgent();
|
|
414
468
|
|