@assistant-ui/react-ag-ui 0.1.3 → 0.1.4
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 +3 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +93 -10
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/AgUiThreadController.snapshot-head-history.test.ts +226 -0
- package/src/runtime/AgUiThreadController.snapshot-nested-settle.test.ts +210 -0
- package/src/runtime/AgUiThreadController.ts +137 -15
|
@@ -300,22 +300,35 @@ const replaceTextParts = (
|
|
|
300
300
|
return result;
|
|
301
301
|
};
|
|
302
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Settles nested sub-agent messages with `status`. Without `runSubagentIds`,
|
|
305
|
+
* only running messages settle. With it, only that run's sub-agent messages
|
|
306
|
+
* settle, and an incomplete `status` also settles one still waiting on its own
|
|
307
|
+
* tool call: the run that could answer it has ended.
|
|
308
|
+
*/
|
|
303
309
|
const settleNestedContent = (
|
|
304
310
|
content: ThreadAssistantMessage["content"],
|
|
305
311
|
status: MessageStatus,
|
|
312
|
+
runSubagentIds?: ReadonlySet<string>,
|
|
306
313
|
) =>
|
|
307
|
-
mapToolCallPartsDeep(content, (part) =>
|
|
308
|
-
part.messages
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
314
|
+
mapToolCallPartsDeep(content, (part) => {
|
|
315
|
+
if (!part.messages) return part;
|
|
316
|
+
let changed = false;
|
|
317
|
+
const messages = part.messages.map((message) => {
|
|
318
|
+
if (message.role !== "assistant") return message;
|
|
319
|
+
const settles = !runSubagentIds
|
|
320
|
+
? message.status?.type === "running"
|
|
321
|
+
: runSubagentIds.has(message.id) &&
|
|
322
|
+
(message.status?.type === "running" ||
|
|
323
|
+
(status.type === "incomplete" &&
|
|
324
|
+
message.status?.type === "requires-action" &&
|
|
325
|
+
message.status.reason === "tool-calls"));
|
|
326
|
+
if (!settles) return message;
|
|
327
|
+
changed = true;
|
|
328
|
+
return { ...message, status };
|
|
329
|
+
});
|
|
330
|
+
return changed ? { ...part, messages } : part;
|
|
331
|
+
}).content;
|
|
319
332
|
|
|
320
333
|
type AgUiRuntimeCallbackName = "onError" | "onCancel";
|
|
321
334
|
|
|
@@ -392,6 +405,8 @@ export class AgUiThreadController {
|
|
|
392
405
|
// The agent that started the active run. updateOptions can swap this.agent
|
|
393
406
|
// mid-run, and cancelling has to reach the agent holding the live request.
|
|
394
407
|
private activeRunAgent: AbstractAgent | null = null;
|
|
408
|
+
// Counts runs started, so a finished run can tell a newer one began.
|
|
409
|
+
private runCount = 0;
|
|
395
410
|
private history: ThreadHistoryAdapter | undefined;
|
|
396
411
|
private readonly assistantHistoryParents = new Map<string, string | null>();
|
|
397
412
|
private readonly subagentParentToolCallIds = new Map<
|
|
@@ -400,6 +415,9 @@ export class AgUiThreadController {
|
|
|
400
415
|
>();
|
|
401
416
|
private readonly snapshotHistoryIds = new Set<string>();
|
|
402
417
|
private readonly persistedHistoryIds = new Set<string>();
|
|
418
|
+
// Ids whose append has not succeeded yet. A write queued behind that append
|
|
419
|
+
// runs as an append too, since an update may not move the stored head.
|
|
420
|
+
private readonly unappendedHistoryIds = new Set<string>();
|
|
403
421
|
private readonly historyWrites = createHistoryWriteChain();
|
|
404
422
|
private _historyImportPromise: Promise<void> | undefined;
|
|
405
423
|
private _loadPromise: Promise<void> | undefined;
|
|
@@ -525,6 +543,7 @@ export class AgUiThreadController {
|
|
|
525
543
|
this.subagentParentToolCallIds.clear();
|
|
526
544
|
this.snapshotHistoryIds.clear();
|
|
527
545
|
this.persistedHistoryIds.clear();
|
|
546
|
+
this.unappendedHistoryIds.clear();
|
|
528
547
|
for (const { message } of repo.messages) {
|
|
529
548
|
this.persistedHistoryIds.add(message.id);
|
|
530
549
|
}
|
|
@@ -1721,6 +1740,8 @@ export class AgUiThreadController {
|
|
|
1721
1740
|
const assistantParentId = parent ? parentId : this.session.headId;
|
|
1722
1741
|
let assistantMessageId: string | undefined;
|
|
1723
1742
|
let snapshotAssistantId: string | undefined;
|
|
1743
|
+
const importedRunAssistants = new Set<string>();
|
|
1744
|
+
const runSubagentIds = new Set<string>();
|
|
1724
1745
|
let replayInterruptMessageId: string | undefined;
|
|
1725
1746
|
// A snapshot the preserve gate declines still evicts the in-flight
|
|
1726
1747
|
// assistant; recreating under the cached id on the next content-bearing
|
|
@@ -2263,9 +2284,38 @@ export class AgUiThreadController {
|
|
|
2263
2284
|
adoptServerMessageId(serverId, true, frameOpenIds);
|
|
2264
2285
|
},
|
|
2265
2286
|
});
|
|
2287
|
+
const settleRunSubagents = (status: MessageStatus) => {
|
|
2288
|
+
if (runSubagentIds.size === 0) return;
|
|
2289
|
+
for (const message of this.session.getMessages()) {
|
|
2290
|
+
if (message.role !== "assistant") continue;
|
|
2291
|
+
const content = settleNestedContent(
|
|
2292
|
+
message.content,
|
|
2293
|
+
status,
|
|
2294
|
+
runSubagentIds,
|
|
2295
|
+
);
|
|
2296
|
+
if (content === message.content) continue;
|
|
2297
|
+
this.updateAssistantMessage(message.id, { content });
|
|
2298
|
+
this.persistSettledAssistant(message.id);
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
// Writes this run's other assistants in branch order: before the answer
|
|
2302
|
+
// settles, the ones above it; after, the ones below. An adapter that
|
|
2303
|
+
// moves its head on each first write then ends on the branch's tail.
|
|
2304
|
+
const persistRunAssistants = (aboveAnswer: boolean) => {
|
|
2305
|
+
for (const message of this.session.getMessages()) {
|
|
2306
|
+
if (message.id === assistantMessageId) {
|
|
2307
|
+
if (aboveAnswer) return;
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
if (message.role === "assistant" && !runStartMessageIds.has(message.id))
|
|
2311
|
+
this.persistAssistantHistory(message.id);
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2266
2314
|
let runFinished = false;
|
|
2267
2315
|
const dispatch = (event: AgUiEvent) => {
|
|
2268
2316
|
if (this.abortController !== abortController) return;
|
|
2317
|
+
if (event.type === "SUBAGENT_STARTED")
|
|
2318
|
+
runSubagentIds.add(event.subagentRunId);
|
|
2269
2319
|
if (event.type === "RUN_FINISHED") {
|
|
2270
2320
|
runFinished = true;
|
|
2271
2321
|
if (event.outcome?.type === "interrupt")
|
|
@@ -2288,6 +2338,7 @@ export class AgUiThreadController {
|
|
|
2288
2338
|
event.type === "RUN_FINISHED" ||
|
|
2289
2339
|
event.type === "RUN_ERROR" ||
|
|
2290
2340
|
event.type === "RUN_CANCELLED";
|
|
2341
|
+
if (settlingRun) persistRunAssistants(true);
|
|
2291
2342
|
const beforeSnapshot =
|
|
2292
2343
|
event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
|
|
2293
2344
|
try {
|
|
@@ -2301,6 +2352,31 @@ export class AgUiThreadController {
|
|
|
2301
2352
|
candidateContexts.size ? reconcileReplaySnapshot : undefined,
|
|
2302
2353
|
);
|
|
2303
2354
|
if (event.type === "MESSAGES_SNAPSHOT") {
|
|
2355
|
+
// Assistants a snapshot adds belong to the run's history even when
|
|
2356
|
+
// the run never streams into them. Every import resets the pending
|
|
2357
|
+
// marks, so each snapshot marks them again. The import also routes
|
|
2358
|
+
// every message to update; a run assistant this client never wrote
|
|
2359
|
+
// gets an append first, which is what moves the stored head.
|
|
2360
|
+
const previousIds = new Set(beforeSnapshot.map(({ id }) => id));
|
|
2361
|
+
for (const message of this.session.getMessages()) {
|
|
2362
|
+
if (
|
|
2363
|
+
message.role !== "assistant" ||
|
|
2364
|
+
runStartMessageIds.has(message.id)
|
|
2365
|
+
)
|
|
2366
|
+
continue;
|
|
2367
|
+
if (!previousIds.has(message.id))
|
|
2368
|
+
importedRunAssistants.add(message.id);
|
|
2369
|
+
if (
|
|
2370
|
+
!this.persistedHistoryIds.has(message.id) &&
|
|
2371
|
+
this.historyWrites.pending(message.id) === undefined
|
|
2372
|
+
)
|
|
2373
|
+
this.snapshotHistoryIds.delete(message.id);
|
|
2374
|
+
}
|
|
2375
|
+
for (const messageId of importedRunAssistants) {
|
|
2376
|
+
const item = this.session.tryGetMessage(messageId);
|
|
2377
|
+
if (item)
|
|
2378
|
+
this.markPendingAssistantHistory(messageId, item.parentId);
|
|
2379
|
+
}
|
|
2304
2380
|
for (const message of beforeSnapshot) {
|
|
2305
2381
|
const context = replayContexts.get(message.id);
|
|
2306
2382
|
const current = this.session.tryGetMessage(message.id)?.message;
|
|
@@ -2332,6 +2408,16 @@ export class AgUiThreadController {
|
|
|
2332
2408
|
tail?.role === "assistant" ? tail.id : undefined;
|
|
2333
2409
|
}
|
|
2334
2410
|
}
|
|
2411
|
+
if (event.type === "RUN_ERROR") {
|
|
2412
|
+
settleRunSubagents({
|
|
2413
|
+
type: "incomplete",
|
|
2414
|
+
reason: "error",
|
|
2415
|
+
...(event.message !== undefined ? { error: event.message } : {}),
|
|
2416
|
+
});
|
|
2417
|
+
} else if (event.type === "RUN_CANCELLED") {
|
|
2418
|
+
settleRunSubagents({ type: "incomplete", reason: "cancelled" });
|
|
2419
|
+
}
|
|
2420
|
+
if (settlingRun) persistRunAssistants(false);
|
|
2335
2421
|
} finally {
|
|
2336
2422
|
settlingRun = false;
|
|
2337
2423
|
}
|
|
@@ -2340,6 +2426,7 @@ export class AgUiThreadController {
|
|
|
2340
2426
|
this.abortController = abortController;
|
|
2341
2427
|
const runAgentInstance = this.agent;
|
|
2342
2428
|
this.activeRunAgent = runAgentInstance;
|
|
2429
|
+
const runNumber = ++this.runCount;
|
|
2343
2430
|
|
|
2344
2431
|
let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
|
|
2345
2432
|
abortSignal.addEventListener(
|
|
@@ -2423,6 +2510,7 @@ export class AgUiThreadController {
|
|
|
2423
2510
|
current?.role === "assistant" &&
|
|
2424
2511
|
current.status?.type === "running"
|
|
2425
2512
|
) {
|
|
2513
|
+
persistRunAssistants(true);
|
|
2426
2514
|
// Finalization is transport cleanup, not proof of a terminal event.
|
|
2427
2515
|
const status: MessageStatus = {
|
|
2428
2516
|
type: "incomplete",
|
|
@@ -2435,6 +2523,25 @@ export class AgUiThreadController {
|
|
|
2435
2523
|
content: settleNestedContent(current.content, status),
|
|
2436
2524
|
});
|
|
2437
2525
|
}
|
|
2526
|
+
if (this.abortController === abortController && !runFinished) {
|
|
2527
|
+
// The run ended without RUN_FINISHED, so no sub-agent call it left
|
|
2528
|
+
// open can still be answered.
|
|
2529
|
+
const settled = assistantMessageId
|
|
2530
|
+
? this.session.tryGetMessage(assistantMessageId)?.message
|
|
2531
|
+
: undefined;
|
|
2532
|
+
settleRunSubagents(
|
|
2533
|
+
settled?.status?.type === "incomplete"
|
|
2534
|
+
? settled.status
|
|
2535
|
+
: {
|
|
2536
|
+
type: "incomplete",
|
|
2537
|
+
reason: "error",
|
|
2538
|
+
error: "The connection ended before the answer finished.",
|
|
2539
|
+
},
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
// Stop may have finished this run already; a newer run owns its own
|
|
2543
|
+
// writes.
|
|
2544
|
+
if (this.runCount === runNumber) persistRunAssistants(false);
|
|
2438
2545
|
this.finishRun(abortController);
|
|
2439
2546
|
}
|
|
2440
2547
|
|
|
@@ -2773,11 +2880,14 @@ export class AgUiThreadController {
|
|
|
2773
2880
|
oldId,
|
|
2774
2881
|
collidesWithExisting ? undefined : newId,
|
|
2775
2882
|
);
|
|
2883
|
+
if (this.unappendedHistoryIds.delete(oldId) && !collidesWithExisting)
|
|
2884
|
+
this.unappendedHistoryIds.add(newId);
|
|
2776
2885
|
if (pendingWrite && !collidesWithExisting) {
|
|
2777
2886
|
void pendingWrite.then(
|
|
2778
2887
|
() => {
|
|
2779
2888
|
this.persistedHistoryIds.delete(oldId);
|
|
2780
2889
|
this.persistedHistoryIds.add(newId);
|
|
2890
|
+
this.unappendedHistoryIds.delete(newId);
|
|
2781
2891
|
},
|
|
2782
2892
|
() => {},
|
|
2783
2893
|
);
|
|
@@ -3859,7 +3969,9 @@ export class AgUiThreadController {
|
|
|
3859
3969
|
|
|
3860
3970
|
if (shouldUpdate) {
|
|
3861
3971
|
const write = this.historyWrites.chain(messageId, () =>
|
|
3862
|
-
|
|
3972
|
+
this.unappendedHistoryIds.has(messageId)
|
|
3973
|
+
? this.appendOnce(history, messageId, { parentId, message })
|
|
3974
|
+
: update.call(history, { parentId, message }),
|
|
3863
3975
|
);
|
|
3864
3976
|
this.assistantHistoryParents.delete(messageId);
|
|
3865
3977
|
void write.then(
|
|
@@ -3897,6 +4009,15 @@ export class AgUiThreadController {
|
|
|
3897
4009
|
);
|
|
3898
4010
|
}
|
|
3899
4011
|
|
|
4012
|
+
private async appendOnce(
|
|
4013
|
+
history: ThreadHistoryAdapter,
|
|
4014
|
+
messageId: string,
|
|
4015
|
+
item: { parentId: string | null; message: ThreadMessage },
|
|
4016
|
+
): Promise<void> {
|
|
4017
|
+
await history.append(item);
|
|
4018
|
+
this.unappendedHistoryIds.delete(messageId);
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3900
4021
|
private appendHistoryItem(
|
|
3901
4022
|
parentId: string | null,
|
|
3902
4023
|
message: ThreadMessage,
|
|
@@ -3905,9 +4026,10 @@ export class AgUiThreadController {
|
|
|
3905
4026
|
const pending = this.historyWrites.pending(message.id);
|
|
3906
4027
|
if (pending) return pending;
|
|
3907
4028
|
|
|
3908
|
-
const
|
|
4029
|
+
const history = this.history;
|
|
4030
|
+
this.unappendedHistoryIds.add(message.id);
|
|
3909
4031
|
const write = this.historyWrites.chain(message.id, () =>
|
|
3910
|
-
|
|
4032
|
+
this.appendOnce(history, message.id, { parentId, message }),
|
|
3911
4033
|
);
|
|
3912
4034
|
void write.then(
|
|
3913
4035
|
() => {
|